// Command safemirror audits EXISTING backups against the 3-2-1 rule. It never
// creates, changes or deletes backups: it reads a declared source tree and a
// declared set of backup locations, compares every file by SHA-256, and reports
// per file how many good copies exist, on how many distinct media, and whether
// any of them is offsite.
package main

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

const version = "1.0.0"

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

// Verdicts, worst first.
const (
	verdictMissing   = "MISSING"
	verdictStale     = "STALE"
	verdictUnderRep  = "UNDER-REPLICATED"
	verdictSingleMed = "SINGLE-MEDIUM"
	verdictNoOffsite = "NO-OFFSITE"
	verdictProtected = "PROTECTED"
)

var verdictOrder = []string{
	verdictMissing, verdictStale, verdictUnderRep,
	verdictSingleMed, verdictNoOffsite, verdictProtected,
}

// Media a location may declare. The tool cannot verify these; the user asserts them.
var knownMedia = []string{"hdd", "ssd", "nas", "optical", "cloud", "tape"}

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, `safemirror %s - 3-2-1 backup rule auditor

SafeMirror does not make backups. It audits the ones you already have and
answers, per file: how many good copies exist, on how many distinct media,
and whether any copy is offsite - then names every file that breaks the rule.

USAGE
  safemirror audit     --config <config.json> [--rule 3-2-1] [--json]
  safemirror locations --config <config.json> [--json]
  safemirror explain   <relative/path> --config <config.json> [--json]
  safemirror config    --example
  safemirror help | -h | --help
  safemirror version

COMMANDS
  audit      Classify every file under the source: PROTECTED, UNDER-REPLICATED,
             SINGLE-MEDIUM, NO-OFFSITE, STALE or MISSING, plus coverage totals
             and the worst offenders by bytes at risk.
  locations  Per backup location: reachability, files present/matching/stale/
             missing, bytes held, declared medium and offsite flag.
  explain    One file: where every copy lives, whether it matches by SHA-256,
             and exactly which part of the rule fails.
  config     --example prints a ready-to-edit config on stdout.

FLAGS
  --config PATH  Config file describing the source and the backup locations.
  --rule A-B-C   At least A good copies, on at least B distinct media, with at
                 least C offsite (default 3-2-1). Try 2-2-1 or 1-1-0.
  --json         Machine-readable report on stdout.

HOW COPIES ARE COUNTED
  The source itself counts as good copy #1, so --rule 3-2-1 needs the source
  plus 2 matching backup copies. Distinct media and offsite copies are counted
  over BACKUP LOCATIONS ONLY: the source has no declared medium and is never
  treated as offsite. Two locations declaring the same medium count once.

CONFIG FORMAT
  {"source":"/data",
   "locations":[{"name":"nas","path":"/mnt/nas","medium":"nas","offsite":false}]}
  medium is one of: hdd, ssd, nas, optical, cloud, tape.

SAFETY
  Every command is strictly read-only. SafeMirror opens files for reading only
  and never creates, copies, moves or deletes anything, so there is no --apply
  flag: there is nothing to apply.

EXIT CODES
  0  audit clean / command completed with no rule violation
  1  usage error, bad config, unreadable source
  2  at least one file violates the rule (or a location is unreachable)

EXAMPLES
  safemirror config --example > backup.json
  safemirror audit --config backup.json
  safemirror audit --config backup.json --rule 2-2-1 --json
  safemirror explain docs/report.txt --config backup.json
`, version)
}

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

type locationCfg struct {
	Name    string `json:"name"`
	Path    string `json:"path"`
	Medium  string `json:"medium"`
	Offsite bool   `json:"offsite"`
}

type config struct {
	Source    string        `json:"source"`
	Locations []locationCfg `json:"locations"`
}

const exampleConfig = `{
  "source": "/home/you/Documents",
  "locations": [
    {
      "name": "nas-main",
      "path": "/mnt/nas/documents",
      "medium": "nas",
      "offsite": false
    },
    {
      "name": "usb-hdd",
      "path": "/media/you/backup-hdd/documents",
      "medium": "hdd",
      "offsite": false
    },
    {
      "name": "offsite-cloud",
      "path": "/mnt/cloud-drive/documents",
      "medium": "cloud",
      "offsite": true
    }
  ]
}
`

func loadConfig(path string) (*config, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read config: %v", err)
	}
	var c config
	if err := json.Unmarshal(raw, &c); err != nil {
		return nil, fmt.Errorf("config %s is not valid JSON: %v", path, err)
	}
	if strings.TrimSpace(c.Source) == "" {
		return nil, fmt.Errorf(`config %s: "source" is missing or empty`, path)
	}
	if len(c.Locations) == 0 {
		return nil, fmt.Errorf(`config %s: "locations" is empty; declare at least one backup location`, path)
	}
	src, err := filepath.Abs(c.Source)
	if err != nil {
		return nil, fmt.Errorf("config %s: source %q: %v", path, c.Source, err)
	}
	c.Source = src

	seenName := map[string]bool{}
	seenPath := map[string]bool{}
	for i := range c.Locations {
		l := &c.Locations[i]
		if strings.TrimSpace(l.Name) == "" {
			return nil, fmt.Errorf("config %s: location #%d has no name", path, i+1)
		}
		if strings.TrimSpace(l.Path) == "" {
			return nil, fmt.Errorf("config %s: location %q has no path", path, l.Name)
		}
		if !isKnownMedium(l.Medium) {
			return nil, fmt.Errorf("config %s: location %q has medium %q; expected one of %s",
				path, l.Name, l.Medium, strings.Join(knownMedia, ", "))
		}
		abs, err := filepath.Abs(l.Path)
		if err != nil {
			return nil, fmt.Errorf("config %s: location %q: %v", path, l.Name, err)
		}
		if seenName[l.Name] {
			return nil, fmt.Errorf("config %s: location name %q is used twice", path, l.Name)
		}
		if seenPath[abs] {
			return nil, fmt.Errorf("config %s: path %s is declared by two locations", path, abs)
		}
		if abs == c.Source {
			return nil, fmt.Errorf("config %s: location %q points at the source directory", path, l.Name)
		}
		seenName[l.Name] = true
		seenPath[abs] = true
		l.Path = abs
	}
	return &c, nil
}

func isKnownMedium(m string) bool {
	for _, k := range knownMedia {
		if m == k {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------------
// Rule
// ---------------------------------------------------------------------------

type rule struct {
	Raw     string `json:"raw"`
	Copies  int    `json:"copies"`
	Media   int    `json:"media"`
	Offsite int    `json:"offsite"`
}

func (r rule) describe() string {
	return fmt.Sprintf(">= %d good copies incl. source, >= %d distinct media, >= %d offsite",
		r.Copies, r.Media, r.Offsite)
}

func parseRule(s string) (rule, error) {
	parts := strings.Split(s, "-")
	if len(parts) != 3 {
		return rule{}, fmt.Errorf("rule %q must have exactly three parts like 3-2-1", s)
	}
	vals := make([]int, 3)
	for i, p := range parts {
		if p == "" || strings.TrimFunc(p, func(r rune) bool { return r >= '0' && r <= '9' }) != "" {
			return rule{}, fmt.Errorf("rule %q: component %q is not a non-negative number", s, p)
		}
		n, err := strconv.Atoi(p)
		if err != nil {
			return rule{}, fmt.Errorf("rule %q: component %q is not a number", s, p)
		}
		if n > 64 {
			return rule{}, fmt.Errorf("rule %q: component %q is unreasonably large (max 64)", s, p)
		}
		vals[i] = n
	}
	return rule{Raw: s, Copies: vals[0], Media: vals[1], Offsite: vals[2]}, nil
}

// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------

type fileEntry struct {
	Rel  string
	Size int64
	Hash string
}

type sourceScan struct {
	Root        string
	Files       []fileEntry
	TotalBytes  int64
	Unsupported []string
}

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

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

func scanSource(root string, workers int) (*sourceScan, error) {
	s := &sourceScan{Root: root}
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, rerr := filepath.Rel(root, path)
		if rerr != nil {
			return rerr
		}
		if rel == "." {
			return nil
		}
		rel = filepath.ToSlash(rel)
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			s.Unsupported = append(s.Unsupported, rel)
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			return ierr
		}
		s.Files = append(s.Files, fileEntry{Rel: rel, Size: info.Size()})
		s.TotalBytes += info.Size()
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("scanning source: %v", err)
	}
	sort.Slice(s.Files, func(i, j int) bool { return s.Files[i].Rel < s.Files[j].Rel })
	sort.Strings(s.Unsupported)

	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(root, filepath.FromSlash(s.Files[i].Rel)))
				mu.Lock()
				if herr != nil && firstErr == nil {
					firstErr = fmt.Errorf("hashing source file %s: %v", s.Files[i].Rel, herr)
				}
				s.Files[i].Hash = h
				mu.Unlock()
			}
		}()
	}
	for i := range s.Files {
		idx <- i
	}
	close(idx)
	wg.Wait()
	if firstErr != nil {
		return nil, firstErr
	}
	return s, nil
}

// ---------------------------------------------------------------------------
// Audit model
// ---------------------------------------------------------------------------

// Copy statuses.
const (
	statusMatch       = "match"
	statusStale       = "stale"
	statusMissing     = "missing"
	statusUnreachable = "unreachable"
	statusError       = "error"
)

type copyStatus struct {
	Location string `json:"location"`
	Path     string `json:"path"`
	Medium   string `json:"medium"`
	Offsite  bool   `json:"offsite"`
	Status   string `json:"status"`
	Size     int64  `json:"size"`
	SHA256   string `json:"sha256,omitempty"`
	Detail   string `json:"detail,omitempty"`
}

type fileResult struct {
	Path          string       `json:"path"`
	Size          int64        `json:"size"`
	SHA256        string       `json:"sha256"`
	Verdict       string       `json:"verdict"`
	GoodCopies    int          `json:"good_copies"`
	BackupCopies  int          `json:"good_backup_copies"`
	StaleCopies   int          `json:"stale_copies"`
	MissingFrom   int          `json:"missing_from_locations"`
	Media         []string     `json:"media"`
	MediaCount    int          `json:"distinct_media"`
	OffsiteCopies int          `json:"offsite_copies"`
	CopiesOK      bool         `json:"copies_ok"`
	MediaOK       bool         `json:"media_ok"`
	OffsiteOK     bool         `json:"offsite_ok"`
	Reasons       []string     `json:"reasons"`
	Notes         []string     `json:"notes"`
	Copies        []copyStatus `json:"copies"`
}

type locationReport struct {
	Name       string `json:"name"`
	Path       string `json:"path"`
	Medium     string `json:"medium"`
	Offsite    bool   `json:"offsite"`
	Reachable  bool   `json:"reachable"`
	Reason     string `json:"reason,omitempty"`
	Present    int    `json:"files_present"`
	Matching   int    `json:"files_matching"`
	Stale      int    `json:"files_stale"`
	Missing    int    `json:"files_missing"`
	Errors     int    `json:"files_errored"`
	BytesHeld  int64  `json:"bytes_held"`
	BytesHuman string `json:"bytes_held_human"`
}

type verdictCount struct {
	Verdict string `json:"verdict"`
	Files   int    `json:"files"`
	Bytes   int64  `json:"bytes"`
}

type offender struct {
	Path    string `json:"path"`
	Verdict string `json:"verdict"`
	Bytes   int64  `json:"bytes"`
	Reason  string `json:"reason"`
}

type coverage struct {
	TotalFiles     int            `json:"total_files"`
	TotalBytes     int64          `json:"total_bytes"`
	Protected      int            `json:"protected_files"`
	ProtectedPct   float64        `json:"protected_percent"`
	BytesAtRisk    int64          `json:"bytes_at_risk"`
	ByVerdict      []verdictCount `json:"by_verdict"`
	WorstOffenders []offender     `json:"worst_offenders"`
}

type auditReport struct {
	Tool         string           `json:"tool"`
	Version      string           `json:"version"`
	Command      string           `json:"command"`
	ReadOnly     bool             `json:"read_only"`
	Config       string           `json:"config"`
	Source       string           `json:"source"`
	Rule         rule             `json:"rule"`
	Locations    []locationReport `json:"locations"`
	Files        []fileResult     `json:"files,omitempty"`
	File         *fileResult      `json:"file,omitempty"`
	Unsupported  []string         `json:"unsupported_entries,omitempty"`
	Coverage     *coverage        `json:"coverage,omitempty"`
	Unreachable  int              `json:"unreachable_locations"`
	ExitCode     int              `json:"exit_code"`
	CountingNote string           `json:"counting_note"`
}

const countingNote = "The source counts as good copy #1. Distinct media and offsite copies are counted over backup locations only."

// locState is the runtime state of one declared location.
type locState struct {
	cfg       locationCfg
	reachable bool
	reason    string
}

// audit performs the whole read-only comparison.
func audit(cfg *config, src *sourceScan, r rule, workers int) ([]fileResult, []locationReport) {
	locs := make([]locState, len(cfg.Locations))
	reports := make([]locationReport, len(cfg.Locations))
	for i, lc := range cfg.Locations {
		locs[i] = locState{cfg: lc}
		reports[i] = locationReport{
			Name: lc.Name, Path: lc.Path, Medium: lc.Medium, Offsite: lc.Offsite,
		}
		info, err := os.Stat(lc.Path)
		switch {
		case err != nil:
			reports[i].Reason = fmt.Sprintf("unreachable: %v", err)
		case !info.IsDir():
			reports[i].Reason = "unreachable: path is not a directory"
		default:
			locs[i].reachable = true
			reports[i].Reachable = true
		}
		locs[i].reason = reports[i].Reason
	}

	results := make([]fileResult, len(src.Files))
	for i, fe := range src.Files {
		results[i] = fileResult{
			Path:   fe.Rel,
			Size:   fe.Size,
			SHA256: fe.Hash,
			Copies: make([]copyStatus, len(cfg.Locations)),
		}
		for j, lc := range cfg.Locations {
			results[i].Copies[j] = copyStatus{
				Location: lc.Name, Medium: lc.Medium, Offsite: lc.Offsite,
				Path: filepath.Join(lc.Path, filepath.FromSlash(fe.Rel)),
			}
		}
	}

	type task struct{ file, loc int }
	tasks := make(chan task)
	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for t := range tasks {
				cs := &results[t.file].Copies[t.loc]
				if !locs[t.loc].reachable {
					cs.Status = statusUnreachable
					cs.Detail = locs[t.loc].reason
					continue
				}
				info, err := os.Stat(cs.Path)
				if err != nil {
					if os.IsNotExist(err) {
						cs.Status = statusMissing
						cs.Detail = "no such file in this location"
					} else {
						cs.Status = statusError
						cs.Detail = err.Error()
					}
					continue
				}
				if !info.Mode().IsRegular() {
					cs.Status = statusError
					cs.Detail = "entry is not a regular file"
					continue
				}
				cs.Size = info.Size()
				h, _, herr := hashFile(cs.Path)
				if herr != nil {
					cs.Status = statusError
					cs.Detail = herr.Error()
					continue
				}
				cs.SHA256 = h
				if h == results[t.file].SHA256 {
					cs.Status = statusMatch
					continue
				}
				cs.Status = statusStale
				cs.Detail = fmt.Sprintf("sha256 %s != source %s", short(h), short(results[t.file].SHA256))
			}
		}()
	}
	for fi := range results {
		for li := range cfg.Locations {
			tasks <- task{file: fi, loc: li}
		}
	}
	close(tasks)
	wg.Wait()

	for i := range results {
		classify(&results[i], r)
		for j := range results[i].Copies {
			cs := results[i].Copies[j]
			lr := &reports[j]
			switch cs.Status {
			case statusMatch:
				lr.Present++
				lr.Matching++
				lr.BytesHeld += cs.Size
			case statusStale:
				lr.Present++
				lr.Stale++
				lr.BytesHeld += cs.Size
			case statusMissing, statusUnreachable:
				lr.Missing++
			case statusError:
				lr.Errors++
			}
		}
	}
	for i := range reports {
		reports[i].BytesHuman = humanBytes(reports[i].BytesHeld)
	}
	return results, reports
}

// classify applies the rule to one file and records exactly which parts fail.
func classify(f *fileResult, r rule) {
	mediaSeen := map[string]bool{}
	var media []string
	for _, cs := range f.Copies {
		switch cs.Status {
		case statusMatch:
			f.BackupCopies++
			if !mediaSeen[cs.Medium] {
				mediaSeen[cs.Medium] = true
				media = append(media, cs.Medium)
			}
			if cs.Offsite {
				f.OffsiteCopies++
			}
		case statusStale:
			f.StaleCopies++
		case statusMissing, statusUnreachable:
			f.MissingFrom++
		}
	}
	sort.Strings(media)
	f.Media = media
	if f.Media == nil {
		f.Media = []string{}
	}
	f.MediaCount = len(media)
	f.GoodCopies = f.BackupCopies + 1 // the source itself is good copy #1
	f.CopiesOK = f.GoodCopies >= r.Copies
	f.MediaOK = f.MediaCount >= r.Media
	f.OffsiteOK = f.OffsiteCopies >= r.Offsite
	f.Reasons = []string{}
	f.Notes = []string{}

	if !f.CopiesOK {
		f.Reasons = append(f.Reasons, fmt.Sprintf(
			"copies: %d good %s (source + %d backup), rule needs %d",
			f.GoodCopies, plural(f.GoodCopies, "copy", "copies"), f.BackupCopies, r.Copies))
	}
	if !f.MediaOK {
		f.Reasons = append(f.Reasons, fmt.Sprintf(
			"media: good copies span %d distinct medium/media %s, rule needs %d",
			f.MediaCount, formatMedia(f.Media), r.Media))
	}
	if !f.OffsiteOK {
		f.Reasons = append(f.Reasons, fmt.Sprintf(
			"offsite: %d offsite good copies, rule needs %d",
			f.OffsiteCopies, r.Offsite))
	}
	if f.StaleCopies > 0 {
		var names []string
		for _, cs := range f.Copies {
			if cs.Status == statusStale {
				names = append(names, cs.Location)
			}
		}
		f.Notes = append(f.Notes, fmt.Sprintf("content drift: %d %s a differing copy (%s)",
			f.StaleCopies, plural(f.StaleCopies, "location holds", "locations hold"),
			strings.Join(names, ", ")))
	}
	if f.MissingFrom > 0 {
		f.Notes = append(f.Notes, fmt.Sprintf("absent from %d of %d locations", f.MissingFrom, len(f.Copies)))
	}

	switch {
	case f.CopiesOK && f.MediaOK && f.OffsiteOK:
		f.Verdict = verdictProtected
	case f.BackupCopies == 0 && f.StaleCopies == 0:
		f.Verdict = verdictMissing
	case f.StaleCopies > 0:
		f.Verdict = verdictStale
	case !f.CopiesOK:
		f.Verdict = verdictUnderRep
	case !f.MediaOK:
		f.Verdict = verdictSingleMed
	default:
		f.Verdict = verdictNoOffsite
	}
	if f.Verdict == verdictMissing && len(f.Reasons) == 0 {
		f.Reasons = append(f.Reasons, "no copy in any backup location")
	}
}

func formatMedia(m []string) string {
	if len(m) == 0 {
		return "(none)"
	}
	return "[" + strings.Join(m, ", ") + "]"
}

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

func summarize(files []fileResult) *coverage {
	c := &coverage{TotalFiles: len(files)}
	counts := map[string]*verdictCount{}
	for _, v := range verdictOrder {
		counts[v] = &verdictCount{Verdict: v}
	}
	var risky []fileResult
	for _, f := range files {
		c.TotalBytes += f.Size
		vc := counts[f.Verdict]
		vc.Files++
		vc.Bytes += f.Size
		if f.Verdict == verdictProtected {
			c.Protected++
			continue
		}
		c.BytesAtRisk += f.Size
		risky = append(risky, f)
	}
	if c.TotalFiles > 0 {
		c.ProtectedPct = float64(c.Protected) * 100 / float64(c.TotalFiles)
	}
	c.ByVerdict = []verdictCount{}
	for _, v := range verdictOrder {
		if counts[v].Files > 0 {
			c.ByVerdict = append(c.ByVerdict, *counts[v])
		}
	}
	sort.SliceStable(risky, func(i, j int) bool {
		if risky[i].Size != risky[j].Size {
			return risky[i].Size > risky[j].Size
		}
		return risky[i].Path < risky[j].Path
	})
	c.WorstOffenders = []offender{}
	for i, f := range risky {
		if i >= 5 {
			break
		}
		reason := ""
		if len(f.Reasons) > 0 {
			reason = f.Reasons[0]
		}
		c.WorstOffenders = append(c.WorstOffenders, offender{
			Path: f.Path, Verdict: f.Verdict, Bytes: f.Size, Reason: reason,
		})
	}
	return c
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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.Stderr)
		os.Exit(exitUsage)
	}
	switch args[0] {
	case "-h", "--help", "-help", "help":
		usage(os.Stdout)
		os.Exit(exitOK)
	case "version", "--version", "-version":
		fmt.Printf("safemirror %s\n", version)
		os.Exit(exitOK)
	}
	switch args[0] {
	case "audit":
		os.Exit(runAudit(args[1:]))
	case "locations":
		os.Exit(runLocations(args[1:]))
	case "explain":
		os.Exit(runExplain(args[1:]))
	case "config":
		os.Exit(runConfig(args[1:]))
	default:
		fmt.Fprintf(os.Stderr, "safemirror: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
}

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

func fail(format string, a ...any) int {
	fmt.Fprintf(os.Stderr, "safemirror: "+format+"\n\n", a...)
	usage(os.Stderr)
	return exitUsage
}

func failPlain(format string, a ...any) int {
	fmt.Fprintf(os.Stderr, "safemirror: "+format+"\n", a...)
	return exitUsage
}

func runConfig(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("config", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	example := fset.Bool("example", false, "print an example config")
	if err := fset.Parse(reorderFlags(args, map[string]bool{})); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("config takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	if !*example {
		return fail("config needs --example")
	}
	fmt.Print(exampleConfig)
	return exitOK
}

// prepare loads config, parses the rule and scans the source for any command
// that needs the full picture.
func prepare(configPath, ruleSpec string) (*config, *sourceScan, rule, int) {
	if strings.TrimSpace(configPath) == "" {
		return nil, nil, rule{}, fail("--config <config.json> is required")
	}
	r, err := parseRule(ruleSpec)
	if err != nil {
		return nil, nil, rule{}, fail("%v", err)
	}
	cfg, err := loadConfig(configPath)
	if err != nil {
		return nil, nil, rule{}, failPlain("%v", err)
	}
	info, err := os.Stat(cfg.Source)
	if err != nil {
		return nil, nil, rule{}, failPlain("cannot read source %s: %v", cfg.Source, err)
	}
	if !info.IsDir() {
		return nil, nil, rule{}, failPlain("source %s is not a directory", cfg.Source)
	}
	scan, err := scanSource(cfg.Source, defaultWorkers())
	if err != nil {
		return nil, nil, rule{}, failPlain("%v", err)
	}
	return cfg, scan, r, 0
}

func emitJSON(rep *auditReport) int {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(rep); err != nil {
		return failPlain("%v", err)
	}
	return rep.ExitCode
}

func runAudit(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("audit", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	configPath := fset.String("config", "", "config file")
	ruleSpec := fset.String("rule", "3-2-1", "rule copies-media-offsite")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{"config": true, "rule": true})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("audit takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	cfg, scan, r, code := prepare(*configPath, *ruleSpec)
	if cfg == nil {
		return code
	}

	files, locs := audit(cfg, scan, r, defaultWorkers())
	cov := summarize(files)
	rep := &auditReport{
		Tool: "safemirror", Version: version, Command: "audit", ReadOnly: true,
		Config: absOr(*configPath), Source: cfg.Source, Rule: r,
		Locations: locs, Files: files, Unsupported: scan.Unsupported,
		Coverage: cov, CountingNote: countingNote,
	}
	for _, l := range locs {
		if !l.Reachable {
			rep.Unreachable++
		}
	}
	rep.ExitCode = exitOK
	if cov.Protected != cov.TotalFiles || rep.Unreachable > 0 {
		rep.ExitCode = exitViolation
	}
	if *asJSON {
		return emitJSON(rep)
	}
	printAudit(os.Stdout, rep)
	return rep.ExitCode
}

func runLocations(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("locations", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	configPath := fset.String("config", "", "config file")
	ruleSpec := fset.String("rule", "3-2-1", "rule copies-media-offsite")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{"config": true, "rule": true})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("locations takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	cfg, scan, r, code := prepare(*configPath, *ruleSpec)
	if cfg == nil {
		return code
	}
	_, locs := audit(cfg, scan, r, defaultWorkers())
	rep := &auditReport{
		Tool: "safemirror", Version: version, Command: "locations", ReadOnly: true,
		Config: absOr(*configPath), Source: cfg.Source, Rule: r,
		Locations: locs, CountingNote: countingNote,
	}
	for _, l := range locs {
		if !l.Reachable {
			rep.Unreachable++
		}
	}
	rep.ExitCode = exitOK
	if rep.Unreachable > 0 {
		rep.ExitCode = exitViolation
	}
	if *asJSON {
		return emitJSON(rep)
	}
	printLocations(os.Stdout, rep, len(scan.Files), scan.TotalBytes)
	return rep.ExitCode
}

func runExplain(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("explain", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	configPath := fset.String("config", "", "config file")
	ruleSpec := fset.String("rule", "3-2-1", "rule copies-media-offsite")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{"config": true, "rule": true})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	rest := fset.Args()
	if len(rest) == 0 {
		return fail("explain needs one path relative to the source")
	}
	if len(rest) > 1 {
		return fail("explain takes exactly one path (got %d: %s)", len(rest), strings.Join(rest, ", "))
	}
	cfg, scan, r, code := prepare(*configPath, *ruleSpec)
	if cfg == nil {
		return code
	}
	want := normalizeRel(rest[0])
	if want == "" {
		return failPlain("%q is not a path relative to the source", rest[0])
	}
	files, locs := audit(cfg, scan, r, defaultWorkers())
	var target *fileResult
	for i := range files {
		if files[i].Path == want {
			target = &files[i]
			break
		}
	}
	if target == nil {
		return failPlain("%s is not a file under the source %s", want, cfg.Source)
	}
	rep := &auditReport{
		Tool: "safemirror", Version: version, Command: "explain", ReadOnly: true,
		Config: absOr(*configPath), Source: cfg.Source, Rule: r,
		Locations: locs, File: target, CountingNote: countingNote,
	}
	for _, l := range locs {
		if !l.Reachable {
			rep.Unreachable++
		}
	}
	rep.ExitCode = exitOK
	if target.Verdict != verdictProtected {
		rep.ExitCode = exitViolation
	}
	if *asJSON {
		return emitJSON(rep)
	}
	printExplain(os.Stdout, rep)
	return rep.ExitCode
}

func normalizeRel(p string) string {
	p = filepath.ToSlash(strings.TrimSpace(p))
	p = strings.TrimPrefix(p, "./")
	p = filepath.ToSlash(filepath.Clean(p))
	if p == "." || p == "/" || strings.HasPrefix(p, "../") || strings.HasPrefix(p, "/") {
		return ""
	}
	return p
}

func absOr(p string) string {
	abs, err := filepath.Abs(p)
	if err != nil {
		return p
	}
	return abs
}

// ---------------------------------------------------------------------------
// Text output
// ---------------------------------------------------------------------------

func printHeader(w io.Writer, rep *auditReport, mode string) {
	fmt.Fprintf(w, "safemirror %s  %s\n", version, mode)
	fmt.Fprintf(w, "config:    %s\n", rep.Config)
	fmt.Fprintf(w, "source:    %s\n", rep.Source)
	fmt.Fprintf(w, "rule:      %s  (%s)\n", rep.Rule.Raw, rep.Rule.describe())
}

func printAudit(w io.Writer, rep *auditReport) {
	printHeader(w, rep, "AUDIT (read-only)")
	fmt.Fprintf(w, "files:     %d (%s)\n", rep.Coverage.TotalFiles, humanBytes(rep.Coverage.TotalBytes))
	fmt.Fprintf(w, "locations: %d declared, %d reachable\n\n", len(rep.Locations), len(rep.Locations)-rep.Unreachable)

	if rep.Unreachable > 0 {
		for _, l := range rep.Locations {
			if !l.Reachable {
				fmt.Fprintf(w, "WARNING: location %q (%s) is UNREACHABLE - %s\n", l.Name, l.Path, l.Reason)
			}
		}
		fmt.Fprintf(w, "         files held there cannot be counted as copies.\n\n")
	}
	if len(rep.Unsupported) > 0 {
		fmt.Fprintf(w, "note: %d non-regular source entries skipped (symlinks/devices are not audited)\n\n",
			len(rep.Unsupported))
	}

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "PATH\tVERDICT\tCOPIES\tMEDIA\tOFFSITE\tSIZE\tSTALE")
	for _, f := range rep.Files {
		fmt.Fprintf(tw, "%s\t%s\t%d/%d\t%d/%d\t%d/%d\t%s\t%d\n",
			f.Path, f.Verdict,
			f.GoodCopies, rep.Rule.Copies,
			f.MediaCount, rep.Rule.Media,
			f.OffsiteCopies, rep.Rule.Offsite,
			humanBytes(f.Size), f.StaleCopies)
	}
	tw.Flush()
	fmt.Fprintln(w)

	violations := 0
	for _, f := range rep.Files {
		if f.Verdict != verdictProtected {
			violations++
		}
	}
	if violations > 0 {
		fmt.Fprintf(w, "VIOLATIONS (%d)\n", violations)
		for _, f := range rep.Files {
			if f.Verdict == verdictProtected {
				continue
			}
			fmt.Fprintf(w, "  %-16s %s (%s)\n", f.Verdict, f.Path, humanBytes(f.Size))
			for _, why := range f.Reasons {
				fmt.Fprintf(w, "      - %s\n", why)
			}
			for _, n := range f.Notes {
				fmt.Fprintf(w, "      . %s\n", n)
			}
		}
		fmt.Fprintln(w)
	}

	tw = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "VERDICT\tFILES\tBYTES")
	for _, vc := range rep.Coverage.ByVerdict {
		fmt.Fprintf(tw, "%s\t%d\t%s\n", vc.Verdict, vc.Files, humanBytes(vc.Bytes))
	}
	tw.Flush()
	fmt.Fprintln(w)

	if len(rep.Coverage.WorstOffenders) > 0 {
		fmt.Fprintln(w, "WORST OFFENDERS BY BYTES AT RISK")
		for _, o := range rep.Coverage.WorstOffenders {
			fmt.Fprintf(w, "  %-10s %-16s %s\n", humanBytes(o.Bytes), o.Verdict, o.Path)
		}
		fmt.Fprintln(w)
	}

	fmt.Fprintf(w, "coverage: %d/%d files fully protected (%.1f%%), %s at risk (%d bytes)\n",
		rep.Coverage.Protected, rep.Coverage.TotalFiles, rep.Coverage.ProtectedPct,
		humanBytes(rep.Coverage.BytesAtRisk), rep.Coverage.BytesAtRisk)
	fmt.Fprintf(w, "counting: %s\n", countingNote)
	if rep.ExitCode != exitOK {
		fmt.Fprintf(w, "RESULT: rule %s NOT met for %d file(s) (exit %d)\n",
			rep.Rule.Raw, violations, exitViolation)
	} else {
		fmt.Fprintf(w, "RESULT: every file meets the %s rule (exit %d)\n", rep.Rule.Raw, exitOK)
	}
}

func printLocations(w io.Writer, rep *auditReport, srcFiles int, srcBytes int64) {
	printHeader(w, rep, "LOCATIONS (read-only)")
	fmt.Fprintf(w, "files:     %d source files (%s) checked against each location\n\n",
		srcFiles, humanBytes(srcBytes))

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "NAME\tMEDIUM\tOFFSITE\tREACHABLE\tPRESENT\tMATCH\tSTALE\tMISSING\tERRORS\tBYTES\tPATH")
	for _, l := range rep.Locations {
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%s\t%s\n",
			l.Name, l.Medium, yesNo(l.Offsite), yesNo(l.Reachable),
			l.Present, l.Matching, l.Stale, l.Missing, l.Errors,
			humanBytes(l.BytesHeld), l.Path)
	}
	tw.Flush()
	fmt.Fprintln(w)

	for _, l := range rep.Locations {
		if !l.Reachable {
			fmt.Fprintf(w, "%s: %s\n", l.Name, l.Reason)
		}
	}
	if rep.Unreachable > 0 {
		fmt.Fprintf(w, "\nRESULT: %d of %d locations unreachable (exit %d)\n",
			rep.Unreachable, len(rep.Locations), exitViolation)
	} else {
		fmt.Fprintf(w, "RESULT: all %d locations reachable (exit %d)\n", len(rep.Locations), exitOK)
	}
	fmt.Fprintf(w, "note: medium and offsite are declared by you; SafeMirror cannot verify them.\n")
}

func printExplain(w io.Writer, rep *auditReport) {
	f := rep.File
	printHeader(w, rep, "EXPLAIN (read-only)")
	fmt.Fprintf(w, "\nfile:      %s\n", f.Path)
	fmt.Fprintf(w, "size:      %s (%d bytes)\n", humanBytes(f.Size), f.Size)
	fmt.Fprintf(w, "sha256:    %s\n", f.SHA256)
	fmt.Fprintf(w, "source:    %s  (counts as good copy #1)\n\n", filepath.Join(rep.Source, filepath.FromSlash(f.Path)))

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "LOCATION\tMEDIUM\tOFFSITE\tSTATUS\tSHA256\tDETAIL")
	for _, cs := range f.Copies {
		h := cs.SHA256
		if h == "" {
			h = "-"
		} else {
			h = short(h)
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
			cs.Location, cs.Medium, yesNo(cs.Offsite), strings.ToUpper(cs.Status), h, cs.Detail)
	}
	tw.Flush()

	fmt.Fprintf(w, "\nRULE %s\n", rep.Rule.Raw)
	tw = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintf(tw, "  copies\thave %d (source + %d matching backups)\tneed %d\t%s\n",
		f.GoodCopies, f.BackupCopies, rep.Rule.Copies, passFail(f.CopiesOK))
	fmt.Fprintf(tw, "  media\thave %d %s\tneed %d\t%s\n",
		f.MediaCount, formatMedia(f.Media), rep.Rule.Media, passFail(f.MediaOK))
	fmt.Fprintf(tw, "  offsite\thave %d\tneed %d\t%s\n",
		f.OffsiteCopies, rep.Rule.Offsite, passFail(f.OffsiteOK))
	tw.Flush()

	if len(f.Reasons) > 0 {
		fmt.Fprintln(w, "\nFAILING COMPONENTS")
		for _, why := range f.Reasons {
			fmt.Fprintf(w, "  - %s\n", why)
		}
	}
	if len(f.Notes) > 0 {
		fmt.Fprintln(w, "\nNOTES")
		for _, n := range f.Notes {
			fmt.Fprintf(w, "  . %s\n", n)
		}
	}
	fmt.Fprintf(w, "\nVERDICT: %s (exit %d)\n", f.Verdict, rep.ExitCode)
}

func plural(n int, one, many string) string {
	if n == 1 {
		return one
	}
	return many
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

func passFail(b bool) string {
	if b {
		return "PASS"
	}
	return "FAIL"
}
