// Command syncproof copies one source tree to many destinations at once and
// verifies every destination independently by SHA-256, reporting a
// per-destination pass/fail matrix.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
	"text/tabwriter"
)

const version = "1.0.0"

// Exit codes.
const (
	exitOK      = 0
	exitUsage   = 1
	exitFailure = 2
)

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `syncproof %s - verified multi-destination copy & sync

SyncProof pushes ONE source tree to N destinations in a single run, then
re-reads every written file and checks its SHA-256 against the source, so you
know exactly which destination came out bad.

USAGE
  syncproof push   <src> --dest <d1> [--dest <d2> ...] [--workers N] [--apply] [--json]
  syncproof verify <src> --dest <d1> [--dest <d2> ...] [--workers N] [--json]
  syncproof help | -h | --help
  syncproof version

COMMANDS
  push     Copy every source file to EVERY destination, then verify each
           destination independently by hash. DRY RUN unless --apply is given.
  verify   No copying at all. Report per destination which files match, which
           are corrupted (hash mismatch) and which are missing.

FLAGS
  --dest PATH    Destination root. Repeat once per destination (required).
  --workers N    Parallel workers across all destination/file pairs
                 (default: number of CPUs, max 8).
  --apply        Actually write. Without it, push only reports the plan.
  --json         Machine-readable report on stdout.

SAFETY
  push is a dry run by default; nothing is written without --apply.
  Data is written to "<name>.part" and renamed only after a successful write,
  so a real filename is never left holding a half-written file.

EXIT CODES
  0  every destination verified OK (or dry run completed)
  1  usage error / source unreadable
  2  at least one destination failed, mismatched or is missing files

EXAMPLES
  syncproof push ./build --dest /mnt/share-a --dest /mnt/share-b --apply
  syncproof verify ./build --dest /mnt/share-a --dest /mnt/share-b --json
`, version)
}

// stringList collects a repeatable flag.
type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
	if strings.TrimSpace(v) == "" {
		return errors.New("destination path must not be empty")
	}
	*s = append(*s, v)
	return nil
}

// fileEntry is one regular file discovered under the source root.
type fileEntry struct {
	Rel  string
	Size int64
	Mode fs.FileMode
	Hash string
}

// plan is the scanned source tree.
type plan struct {
	Src         string
	Files       []fileEntry
	Dirs        []string
	TotalBytes  int64
	Unsupported []string
}

// fileProblem is a single non-OK file outcome inside one destination.
type fileProblem struct {
	Path   string `json:"path"`
	Status string `json:"status"`
	Detail string `json:"detail,omitempty"`
}

// destResult is one row of the destination matrix.
type destResult struct {
	Dest       string        `json:"dest"`
	Status     string        `json:"status"`
	Files      int           `json:"files"`
	Copied     int           `json:"copied"`
	Skipped    int           `json:"skipped"`
	VerifiedOK int           `json:"verified_ok"`
	Mismatched int           `json:"mismatched"`
	Missing    int           `json:"missing"`
	Errors     int           `json:"errors"`
	BytesWrite int64         `json:"bytes_written"`
	Reason     string        `json:"reason,omitempty"`
	Problems   []fileProblem `json:"problems"`
}

// report is the full run result.
type report struct {
	Tool         string       `json:"tool"`
	Version      string       `json:"version"`
	Command      string       `json:"command"`
	Source       string       `json:"source"`
	Apply        bool         `json:"apply"`
	DryRun       bool         `json:"dry_run"`
	Workers      int          `json:"workers"`
	SourceFiles  int          `json:"source_files"`
	SourceBytes  int64        `json:"source_bytes"`
	Destinations []destResult `json:"destinations"`
	Summary      summary      `json:"summary"`
	ExitCode     int          `json:"exit_code"`
}

type summary struct {
	Destinations int `json:"destinations"`
	OK           int `json:"ok"`
	Failed       int `json:"failed"`
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions 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.Stderr)
		os.Exit(exitUsage)
	}
	switch args[0] {
	case "-h", "--help", "-help", "help":
		usage(os.Stdout)
		os.Exit(exitOK)
	case "version", "--version", "-version":
		fmt.Printf("syncproof %s\n", version)
		os.Exit(exitOK)
	}
	switch args[0] {
	case "push":
		os.Exit(run("push", args[1:]))
	case "verify":
		os.Exit(run("verify", args[1:]))
	default:
		fmt.Fprintf(os.Stderr, "syncproof: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
}

func defaultWorkers() int {
	n := runtime.NumCPU()
	if n < 1 {
		n = 1
	}
	if n > 8 {
		n = 8
	}
	return n
}

func run(cmd string, args []string) int {
	fset := flag.NewFlagSet(cmd, flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	var dests stringList
	fset.Var(&dests, "dest", "destination root (repeatable)")
	workers := fset.Int("workers", defaultWorkers(), "parallel workers")
	asJSON := fset.Bool("json", false, "machine-readable output")
	apply := fset.Bool("apply", false, "actually write (push only)")

	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			usage(os.Stdout)
			return exitOK
		}
	}
	args = reorderFlags(args, map[string]bool{"dest": true, "workers": true})
	if err := fset.Parse(args); err != nil {
		fmt.Fprintf(os.Stderr, "syncproof: %v\n\n", err)
		usage(os.Stderr)
		return exitUsage
	}
	if cmd == "verify" && *apply {
		fmt.Fprintf(os.Stderr, "syncproof: verify never writes; --apply is not valid here\n\n")
		usage(os.Stderr)
		return exitUsage
	}
	rest := fset.Args()
	if len(rest) == 0 {
		fmt.Fprintf(os.Stderr, "syncproof: %s needs a source directory\n\n", cmd)
		usage(os.Stderr)
		return exitUsage
	}
	if len(rest) > 1 {
		fmt.Fprintf(os.Stderr, "syncproof: only one source may be given (got %d: %s)\n\n",
			len(rest), strings.Join(rest, ", "))
		usage(os.Stderr)
		return exitUsage
	}
	if len(dests) == 0 {
		fmt.Fprintf(os.Stderr, "syncproof: no destinations given; use --dest PATH at least once\n\n")
		usage(os.Stderr)
		return exitUsage
	}
	if *workers < 1 {
		fmt.Fprintf(os.Stderr, "syncproof: --workers must be >= 1 (got %d)\n\n", *workers)
		usage(os.Stderr)
		return exitUsage
	}

	src, err := filepath.Abs(rest[0])
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncproof: %v\n", err)
		return exitUsage
	}
	info, err := os.Stat(src)
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncproof: cannot read source: %v\n", err)
		return exitUsage
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "syncproof: source %s is not a directory\n", src)
		return exitUsage
	}

	cleanDests, err := normalizeDests(src, dests)
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncproof: %v\n", err)
		return exitUsage
	}

	p, err := scanSource(src, *workers)
	if err != nil {
		fmt.Fprintf(os.Stderr, "syncproof: %v\n", err)
		return exitUsage
	}

	var results []destResult
	if cmd == "push" {
		results = pushAll(p, cleanDests, *workers, *apply)
	} else {
		results = verifyAll(p, cleanDests, *workers)
	}

	rep := report{
		Tool:         "syncproof",
		Version:      version,
		Command:      cmd,
		Source:       src,
		Apply:        *apply,
		DryRun:       cmd == "push" && !*apply,
		Workers:      *workers,
		SourceFiles:  len(p.Files),
		SourceBytes:  p.TotalBytes,
		Destinations: results,
	}
	for _, r := range results {
		if r.Status == "OK" {
			rep.Summary.OK++
		} else {
			rep.Summary.Failed++
		}
	}
	rep.Summary.Destinations = len(results)
	rep.ExitCode = exitOK
	if rep.Summary.Failed > 0 {
		rep.ExitCode = exitFailure
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(rep); err != nil {
			fmt.Fprintf(os.Stderr, "syncproof: %v\n", err)
			return exitUsage
		}
		return rep.ExitCode
	}
	printReport(os.Stdout, &rep, p)
	return rep.ExitCode
}

// normalizeDests absolutises destinations and rejects obviously unsafe ones.
func normalizeDests(src string, dests []string) ([]string, error) {
	seen := map[string]bool{}
	out := make([]string, 0, len(dests))
	for _, d := range dests {
		abs, err := filepath.Abs(d)
		if err != nil {
			return nil, fmt.Errorf("destination %q: %v", d, err)
		}
		if abs == src {
			return nil, fmt.Errorf("destination %q is the source directory", d)
		}
		if strings.HasPrefix(abs+string(filepath.Separator), src+string(filepath.Separator)) {
			return nil, fmt.Errorf("destination %q is inside the source tree", d)
		}
		if seen[abs] {
			return nil, fmt.Errorf("destination %q was given twice", d)
		}
		seen[abs] = true
		out = append(out, abs)
	}
	return out, nil
}

// scanSource walks the source tree and hashes every regular file.
func scanSource(src string, workers int) (*plan, error) {
	p := &plan{Src: src}
	err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, rerr := filepath.Rel(src, path)
		if rerr != nil {
			return rerr
		}
		if rel == "." {
			return nil
		}
		if d.IsDir() {
			p.Dirs = append(p.Dirs, rel)
			return nil
		}
		if !d.Type().IsRegular() {
			p.Unsupported = append(p.Unsupported, rel)
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			return ierr
		}
		p.Files = append(p.Files, fileEntry{Rel: rel, Size: info.Size(), Mode: info.Mode().Perm()})
		p.TotalBytes += info.Size()
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("scanning source: %v", err)
	}
	sort.Slice(p.Files, func(i, j int) bool { return p.Files[i].Rel < p.Files[j].Rel })
	sort.Strings(p.Dirs)
	sort.Strings(p.Unsupported)

	// Hash the source files in parallel.
	var mu sync.Mutex
	var firstErr error
	idx := make(chan int)
	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := range idx {
				h, herr := hashFile(filepath.Join(src, p.Files[i].Rel))
				mu.Lock()
				if herr != nil && firstErr == nil {
					firstErr = fmt.Errorf("hashing source file %s: %v", p.Files[i].Rel, herr)
				}
				p.Files[i].Hash = h
				mu.Unlock()
			}
		}()
	}
	for i := range p.Files {
		idx <- i
	}
	close(idx)
	wg.Wait()
	if firstErr != nil {
		return nil, firstErr
	}
	return p, nil
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// task is one (destination, file) unit of work.
type task struct {
	dest int
	file int
}

// outcome is the result of one task.
type outcome struct {
	dest    int
	status  string // copied | skipped | mismatch | missing | error | plan-copy | plan-skip
	rel     string
	detail  string
	written int64
}

// prepareDest creates the destination root and directory skeleton.
// It returns a reason string when the destination cannot be used at all.
func prepareDest(dest string) string {
	if err := os.MkdirAll(dest, 0o755); err != nil {
		return fmt.Sprintf("cannot create destination root: %v", err)
	}
	info, err := os.Stat(dest)
	if err != nil {
		return fmt.Sprintf("cannot stat destination root: %v", err)
	}
	if !info.IsDir() {
		return "destination root is not a directory"
	}
	return ""
}

func pushAll(p *plan, dests []string, workers int, apply bool) []destResult {
	results := make([]destResult, len(dests))
	for i, d := range dests {
		results[i] = destResult{Dest: d, Files: len(p.Files), Status: "OK", Problems: []fileProblem{}}
	}

	usable := make([]bool, len(dests))
	for i, d := range dests {
		if !apply {
			usable[i] = true
			continue
		}
		if reason := prepareDest(d); reason != "" {
			results[i].Status = "FAILED"
			results[i].Reason = reason
			results[i].Errors = len(p.Files)
			results[i].Problems = append(results[i].Problems, fileProblem{
				Path: ".", Status: "error", Detail: reason,
			})
			continue
		}
		usable[i] = true
		// Replicate the directory skeleton (including empty directories).
		for _, rel := range p.Dirs {
			if err := os.MkdirAll(filepath.Join(d, rel), 0o755); err != nil {
				if results[i].Reason == "" {
					results[i].Reason = fmt.Sprintf("cannot create directory %s: %v", rel, err)
				}
			}
		}
	}

	tasks := make(chan task)
	out := make(chan outcome, 64)
	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for t := range tasks {
				out <- pushOne(p, dests[t.dest], t, apply)
			}
		}()
	}
	go func() {
		for di := range dests {
			if !usable[di] {
				continue
			}
			for fi := range p.Files {
				tasks <- task{dest: di, file: fi}
			}
		}
		close(tasks)
		wg.Wait()
		close(out)
	}()

	for o := range out {
		applyOutcome(&results[o.dest], o)
	}
	finalize(results)
	return results
}

// pushOne copies (or plans to copy) one file into one destination and verifies it.
func pushOne(p *plan, dest string, t task, apply bool) outcome {
	fe := p.Files[t.file]
	srcPath := filepath.Join(p.Src, fe.Rel)
	dstPath := filepath.Join(dest, fe.Rel)

	// Already identical? Then this file is skipped, not re-copied.
	if info, err := os.Stat(dstPath); err == nil && info.Mode().IsRegular() && info.Size() == fe.Size {
		if h, herr := hashFile(dstPath); herr == nil && h == fe.Hash {
			if apply {
				return outcome{dest: t.dest, status: "skipped", rel: fe.Rel}
			}
			return outcome{dest: t.dest, status: "plan-skip", rel: fe.Rel}
		}
	}
	if !apply {
		return outcome{dest: t.dest, status: "plan-copy", rel: fe.Rel, written: fe.Size}
	}

	if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: err.Error()}
	}
	part := dstPath + ".part"
	if err := copyToPart(srcPath, part, fe.Mode); err != nil {
		os.Remove(part)
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: err.Error()}
	}
	if err := os.Rename(part, dstPath); err != nil {
		os.Remove(part)
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: err.Error()}
	}
	// Re-read what actually landed on the destination and compare hashes.
	got, err := hashFile(dstPath)
	if err != nil {
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: "re-read failed: " + err.Error()}
	}
	if got != fe.Hash {
		return outcome{
			dest: t.dest, status: "mismatch", rel: fe.Rel,
			detail: fmt.Sprintf("sha256 %s != %s", short(got), short(fe.Hash)),
		}
	}
	return outcome{dest: t.dest, status: "copied", rel: fe.Rel, written: fe.Size}
}

func copyToPart(srcPath, part string, mode fs.FileMode) error {
	in, err := os.Open(srcPath)
	if err != nil {
		return err
	}
	defer in.Close()
	if mode == 0 {
		mode = 0o644
	}
	out, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	if err := out.Sync(); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

func verifyAll(p *plan, dests []string, workers int) []destResult {
	results := make([]destResult, len(dests))
	usable := make([]bool, len(dests))
	for i, d := range dests {
		results[i] = destResult{Dest: d, Files: len(p.Files), Status: "OK", Problems: []fileProblem{}}
		info, err := os.Stat(d)
		if err != nil {
			results[i].Status = "FAILED"
			results[i].Reason = fmt.Sprintf("destination unreadable: %v", err)
			results[i].Missing = len(p.Files)
			continue
		}
		if !info.IsDir() {
			results[i].Status = "FAILED"
			results[i].Reason = "destination is not a directory"
			results[i].Missing = len(p.Files)
			continue
		}
		usable[i] = true
	}

	tasks := make(chan task)
	out := make(chan outcome, 64)
	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for t := range tasks {
				out <- verifyOne(p, dests[t.dest], t)
			}
		}()
	}
	go func() {
		for di := range dests {
			if !usable[di] {
				continue
			}
			for fi := range p.Files {
				tasks <- task{dest: di, file: fi}
			}
		}
		close(tasks)
		wg.Wait()
		close(out)
	}()

	for o := range out {
		applyOutcome(&results[o.dest], o)
	}
	finalize(results)
	return results
}

func verifyOne(p *plan, dest string, t task) outcome {
	fe := p.Files[t.file]
	dstPath := filepath.Join(dest, fe.Rel)
	info, err := os.Stat(dstPath)
	if err != nil {
		if os.IsNotExist(err) {
			return outcome{dest: t.dest, status: "missing", rel: fe.Rel, detail: "not present in destination"}
		}
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: err.Error()}
	}
	if !info.Mode().IsRegular() {
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: "destination entry is not a regular file"}
	}
	got, err := hashFile(dstPath)
	if err != nil {
		return outcome{dest: t.dest, status: "error", rel: fe.Rel, detail: err.Error()}
	}
	if got != fe.Hash {
		return outcome{
			dest: t.dest, status: "mismatch", rel: fe.Rel,
			detail: fmt.Sprintf("sha256 %s != %s (size %d vs %d)", short(got), short(fe.Hash), info.Size(), fe.Size),
		}
	}
	return outcome{dest: t.dest, status: "ok", rel: fe.Rel}
}

func applyOutcome(r *destResult, o outcome) {
	switch o.status {
	case "copied":
		r.Copied++
		r.VerifiedOK++
		r.BytesWrite += o.written
	case "skipped":
		r.Skipped++
		r.VerifiedOK++
	case "plan-copy":
		r.Copied++
		r.BytesWrite += o.written
	case "plan-skip":
		r.Skipped++
		r.VerifiedOK++
	case "ok":
		r.VerifiedOK++
	case "mismatch":
		r.Mismatched++
		r.Problems = append(r.Problems, fileProblem{Path: o.rel, Status: "corrupted", Detail: o.detail})
	case "missing":
		r.Missing++
		r.Problems = append(r.Problems, fileProblem{Path: o.rel, Status: "missing", Detail: o.detail})
	case "error":
		r.Errors++
		r.Problems = append(r.Problems, fileProblem{Path: o.rel, Status: "error", Detail: o.detail})
	}
}

func finalize(results []destResult) {
	for i := range results {
		r := &results[i]
		sort.Slice(r.Problems, func(a, b int) bool { return r.Problems[a].Path < r.Problems[b].Path })
		if r.Status == "FAILED" {
			if r.Reason == "" {
				r.Reason = "destination unusable"
			}
			continue
		}
		if r.Mismatched > 0 || r.Missing > 0 || r.Errors > 0 {
			r.Status = "FAILED"
			if r.Reason == "" {
				r.Reason = summarizeProblems(r)
			}
		}
	}
}

func summarizeProblems(r *destResult) string {
	var parts []string
	if r.Mismatched > 0 {
		parts = append(parts, fmt.Sprintf("%d corrupted", r.Mismatched))
	}
	if r.Missing > 0 {
		parts = append(parts, fmt.Sprintf("%d missing", r.Missing))
	}
	if r.Errors > 0 {
		parts = append(parts, fmt.Sprintf("%d errored", r.Errors))
	}
	return strings.Join(parts, ", ")
}

func short(h string) string {
	if len(h) > 12 {
		return h[:12]
	}
	return h
}

func printReport(w io.Writer, rep *report, p *plan) {
	mode := "PUSH (applied)"
	if rep.DryRun {
		mode = "PUSH (DRY RUN - nothing written; re-run with --apply)"
	}
	if rep.Command == "verify" {
		mode = "VERIFY (read-only)"
	}
	fmt.Fprintf(w, "syncproof %s  %s\n", version, mode)
	fmt.Fprintf(w, "source: %s\n", rep.Source)
	fmt.Fprintf(w, "files:  %d (%s)  destinations: %d  workers: %d\n\n",
		rep.SourceFiles, humanBytes(rep.SourceBytes), len(rep.Destinations), rep.Workers)

	if len(p.Unsupported) > 0 {
		fmt.Fprintf(w, "note: %d non-regular entries skipped (symlinks/devices are not copied)\n\n", len(p.Unsupported))
	}

	copiedHdr := "COPIED"
	if rep.DryRun {
		copiedHdr = "TO-COPY"
	}
	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintf(tw, "DESTINATION\tSTATUS\tFILES\t%s\tSKIPPED\tVERIFIED\tMISMATCH\tMISSING\tERRORS\n", copiedHdr)
	for _, r := range rep.Destinations {
		fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
			r.Dest, r.Status, r.Files, r.Copied, r.Skipped, r.VerifiedOK, r.Mismatched, r.Missing, r.Errors)
	}
	tw.Flush()

	fmt.Fprintln(w)
	for _, r := range rep.Destinations {
		if r.Status == "OK" && len(r.Problems) == 0 {
			continue
		}
		fmt.Fprintf(w, "%s: %s", r.Dest, r.Status)
		if r.Reason != "" {
			fmt.Fprintf(w, " - %s", r.Reason)
		}
		fmt.Fprintln(w)
		shown := r.Problems
		const maxShown = 20
		truncated := 0
		if len(shown) > maxShown {
			truncated = len(shown) - maxShown
			shown = shown[:maxShown]
		}
		for _, pr := range shown {
			fmt.Fprintf(w, "    %-9s %s", strings.ToUpper(pr.Status), pr.Path)
			if pr.Detail != "" {
				fmt.Fprintf(w, "  (%s)", pr.Detail)
			}
			fmt.Fprintln(w)
		}
		if truncated > 0 {
			fmt.Fprintf(w, "    ... and %d more\n", truncated)
		}
		fmt.Fprintln(w)
	}

	var written int64
	for _, r := range rep.Destinations {
		written += r.BytesWrite
	}
	verb := "written"
	if rep.DryRun {
		verb = "would be written"
	}
	if rep.Command == "verify" {
		fmt.Fprintf(w, "%d/%d destinations OK\n", rep.Summary.OK, rep.Summary.Destinations)
	} else {
		fmt.Fprintf(w, "%d/%d destinations OK, %s %s across all destinations\n",
			rep.Summary.OK, rep.Summary.Destinations, humanBytes(written), verb)
	}
	if rep.Summary.Failed > 0 {
		fmt.Fprintf(w, "FAILED: %d destination(s) did not verify (exit %d)\n", rep.Summary.Failed, exitFailure)
	}
}
