// Command pchealth is the fleet rollup tool of the Hardware Health Hub.
//
// "collect" writes one machine's health report as JSON; "rollup" merges any
// number of those reports into a single fleet-wide view with per-machine
// rows, fleet aggregates and automatic statistical outlier flagging.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"math"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	appName        = "pchealth"
	appVersion     = "1.0.0"
	reportSchema   = "pchealth.report.v1"
	rollupSchema   = "pchealth.rollup.v1"
	defaultStdDevT = 2.0
	// stdDevKind documents which standard deviation this tool computes.
	// pchealth treats the ingested reports as the WHOLE fleet, not a sample
	// drawn from a larger one, so it uses the POPULATION standard deviation
	// (divide the sum of squared deviations by n, not by n-1).
	stdDevKind = "population"
)

// ---------------------------------------------------------------------------
// shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

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

// humanFloatBytes renders a fractional byte count (a mean or a standard
// deviation) with the same unit ladder humanBytes uses.
func humanFloatBytes(f float64) string {
	if math.IsNaN(f) || math.IsInf(f, 0) {
		return "n/a"
	}
	neg := ""
	if f < 0 {
		neg, f = "-", -f
	}
	const unit = 1024.0
	if f < unit {
		return fmt.Sprintf("%s%.0f B", neg, f)
	}
	units := "KMGTPE"
	div, exp := unit, 0
	for f/div >= unit && exp < len(units)-1 {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%s%.1f %ciB", neg, f/div, units[exp])
}

// comma renders an integer with thousands separators.
func comma(n int64) string {
	s := strconv.FormatInt(n, 10)
	neg := ""
	if strings.HasPrefix(s, "-") {
		neg, s = "-", s[1:]
	}
	var b strings.Builder
	for i, c := range s {
		if i > 0 && (len(s)-i)%3 == 0 {
			b.WriteByte(',')
		}
		b.WriteRune(c)
	}
	return neg + b.String()
}

// ---------------------------------------------------------------------------
// data model
// ---------------------------------------------------------------------------

// PathUsage is the recursive on-disk usage of one watched path.
type PathUsage struct {
	Path   string `json:"path"`
	Bytes  int64  `json:"bytes"`
	Files  int64  `json:"files"`
	Dirs   int64  `json:"dirs"`
	Errors int    `json:"errors"`
}

// MemUsage is this process's Go runtime memory usage at collect time.
type MemUsage struct {
	AllocBytes      uint64 `json:"alloc_bytes"`
	TotalAllocBytes uint64 `json:"total_alloc_bytes"`
	SysBytes        uint64 `json:"sys_bytes"`
	HeapAllocBytes  uint64 `json:"heap_alloc_bytes"`
	HeapSysBytes    uint64 `json:"heap_sys_bytes"`
	NumGC           uint32 `json:"num_gc"`
}

// Report is one machine's health report, as written by "collect".
type Report struct {
	Schema     string      `json:"schema"`
	Tool       string      `json:"tool"`
	Version    string      `json:"version"`
	Machine    string      `json:"machine"`
	Hostname   string      `json:"hostname"`
	Timestamp  string      `json:"timestamp"`
	OS         string      `json:"os"`
	Arch       string      `json:"arch"`
	GoVersion  string      `json:"go_version"`
	NumCPU     int         `json:"num_cpu"`
	Memory     MemUsage    `json:"memory"`
	Paths      []PathUsage `json:"paths"`
	TotalBytes int64       `json:"total_bytes"`
}

// MachineRow is one machine's line in the fleet rollup.
type MachineRow struct {
	Machine    string  `json:"machine"`
	Hostname   string  `json:"hostname"`
	Timestamp  string  `json:"timestamp"`
	OS         string  `json:"os"`
	Arch       string  `json:"arch"`
	NumCPU     int     `json:"num_cpu"`
	PathCount  int     `json:"path_count"`
	TotalBytes int64   `json:"total_bytes"`
	SharePct   float64 `json:"share_pct"`
	ZScore     float64 `json:"z_score"`
	Outlier    bool    `json:"outlier"`
	Direction  string  `json:"direction,omitempty"`
	SourceFile string  `json:"source_file"`
}

// Extreme names the machine holding a fleet minimum or maximum.
type Extreme struct {
	Machine string `json:"machine"`
	Bytes   int64  `json:"bytes"`
}

// FleetStats holds the fleet-wide aggregates and usage statistics.
type FleetStats struct {
	MachineCount int     `json:"machine_count"`
	TotalCPUs    int     `json:"total_cpus"`
	TotalBytes   int64   `json:"total_bytes"`
	MeanBytes    float64 `json:"mean_bytes"`
	MedianBytes  float64 `json:"median_bytes"`
	StdDevBytes  float64 `json:"stddev_bytes"`
	StdDevKind   string  `json:"stddev_kind"`
	Min          Extreme `json:"min"`
	Max          Extreme `json:"max"`
}

// Skipped records a report that could not be ingested, and why.
type Skipped struct {
	File   string `json:"file"`
	Reason string `json:"reason"`
}

// Superseded records a duplicate report for a machine that a newer report
// replaced.
type Superseded struct {
	Machine string `json:"machine"`
	File    string `json:"file"`
	Reason  string `json:"reason"`
}

// Rollup is the complete fleet view, and the shape of --json output.
type Rollup struct {
	Schema          string       `json:"schema"`
	Tool            string       `json:"tool"`
	Version         string       `json:"version"`
	Generated       string       `json:"generated"`
	StdDevThreshold float64      `json:"stddev_threshold"`
	FilesSeen       int          `json:"files_seen"`
	ReportsIngested int          `json:"reports_ingested"`
	Machines        []MachineRow `json:"machines"`
	Fleet           FleetStats   `json:"fleet"`
	Outliers        []MachineRow `json:"outliers"`
	Skipped         []Skipped    `json:"skipped"`
	Superseded      []Superseded `json:"superseded"`
}

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s %s - fleet rollup for the Hardware Health Hub

Collect one health report per machine, then roll many reports up into a
single fleet view with statistical outlier detection, so the one bad machine
out of forty is obvious at a glance.

USAGE
  %s collect --machine <name> --watch <dir> [--watch <dir> ...] --out <report.json>
  %s rollup <report1.json> [report2.json ...] [--stddev-threshold N] [--json]
  %s rollup --dir <reports-dir> [--stddev-threshold N] [--json]
  %s help

COMMANDS
  collect   Write ONE machine's report: machine name, hostname, timestamp,
            OS/arch, logical CPU count (runtime.NumCPU), Go runtime memory
            stats, and the recursive on-disk byte usage of each --watch path.
            This is the agent you run on every machine.

  rollup    Merge N reports into one fleet view: a per-machine table, fleet
            totals, and the mean, median and %s standard deviation of
            per-machine disk usage, with machines further than
            --stddev-threshold standard deviations from the fleet mean
            flagged as OUTLIERS.

COLLECT FLAGS
  --machine <name>   Machine label recorded in the report (required).
  --watch <dir>      Directory (or file) to measure; repeat for several.
  --out <file>       Where to write the JSON report; "-" writes to stdout.

ROLLUP FLAGS
  --dir <dir>            Ingest every *.json in this directory (repeatable).
  --stddev-threshold N   Outlier cutoff in standard deviations (default %.1f).
  --json                 Emit the whole rollup as JSON instead of a table.

NOTES
  Flags may appear before or after positional arguments.
  Unreadable or malformed reports are skipped with a reason; the remaining
  reports still roll up. If no report can be read at all, %s exits 1.
  Standard deviation is the %s standard deviation (divide by n).
  With fewer than two machines, or when every machine is identical, the
  standard deviation is 0 and nothing is flagged as an outlier.

EXAMPLES
  %s collect --machine ws-014 --watch /home/dev --out reports/ws-014.json
  %s rollup reports/*.json --stddev-threshold 1.5
  %s rollup --dir reports --json
`, appName, appVersion,
		appName, appName, appName, appName,
		stdDevKind, defaultStdDevT, appName, stdDevKind,
		appName, appName, appName)
}

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

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

func isHelp(a string) bool {
	switch a {
	case "-h", "--help", "help", "-help":
		return true
	}
	return false
}

func hasHelp(args []string) bool {
	for _, a := range args {
		if isHelp(a) {
			return true
		}
	}
	return false
}

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

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
		}
		fmt.Fprintf(os.Stderr, "%s: no command given\n", appName)
		usage(os.Stderr)
		os.Exit(1)
	}
	if isHelp(args[0]) {
		usage(os.Stdout)
		os.Exit(0)
	}
	switch args[0] {
	case "collect":
		cmdCollect(args[1:])
	case "rollup":
		cmdRollup(args[1:])
	case "version", "--version":
		fmt.Printf("%s %s\n", appName, appVersion)
	default:
		fail("unknown command %q", args[0])
	}
}

// ---------------------------------------------------------------------------
// collect
// ---------------------------------------------------------------------------

type multiFlag []string

func (m *multiFlag) String() string { return strings.Join(*m, ",") }

func (m *multiFlag) Set(v string) error {
	if strings.TrimSpace(v) == "" {
		return errors.New("value must not be empty")
	}
	*m = append(*m, v)
	return nil
}

func cmdCollect(argv []string) {
	if hasHelp(argv) {
		usage(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{"machine": true, "watch": true, "out": true})

	fset := flag.NewFlagSet("collect", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	machine := fset.String("machine", "", "machine name")
	out := fset.String("out", "", "output report path")
	var watches multiFlag
	fset.Var(&watches, "watch", "directory to measure")
	if err := fset.Parse(argv); err != nil {
		fail("collect: %v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		fail("collect: unexpected argument %q (did you mean --watch %s?)", rest[0], rest[0])
	}
	if strings.TrimSpace(*machine) == "" {
		fail("collect: --machine <name> is required")
	}
	if len(watches) == 0 {
		fail("collect: at least one --watch <dir> is required")
	}
	if strings.TrimSpace(*out) == "" {
		fail("collect: --out <report.json> is required")
	}

	rep, err := collect(*machine, watches)
	if err != nil {
		errExit("collect: %v", err)
	}
	blob, err := json.MarshalIndent(rep, "", "  ")
	if err != nil {
		errExit("collect: encoding report: %v", err)
	}
	blob = append(blob, '\n')

	if *out == "-" {
		os.Stdout.Write(blob)
		return
	}
	if dir := filepath.Dir(*out); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			errExit("collect: creating %s: %v", dir, err)
		}
	}
	if err := os.WriteFile(*out, blob, 0o644); err != nil {
		errExit("collect: writing %s: %v", *out, err)
	}

	fmt.Printf("machine %s: %d watched path(s), %s across %s file(s)\n",
		rep.Machine, len(rep.Paths), humanBytes(rep.TotalBytes), comma(totalFiles(rep.Paths)))
	for _, p := range rep.Paths {
		note := ""
		if p.Errors > 0 {
			note = fmt.Sprintf("  (%d unreadable entr(y|ies) skipped)", p.Errors)
		}
		fmt.Printf("  %-40s %12s  %s files%s\n", p.Path, humanBytes(p.Bytes), comma(p.Files), note)
	}
	fmt.Printf("report written to %s\n", *out)
}

func totalFiles(ps []PathUsage) int64 {
	var n int64
	for _, p := range ps {
		n += p.Files
	}
	return n
}

func collect(machine string, watches []string) (*Report, error) {
	host, err := os.Hostname()
	if err != nil || host == "" {
		host = "unknown"
	}
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)

	rep := &Report{
		Schema:    reportSchema,
		Tool:      appName,
		Version:   appVersion,
		Machine:   machine,
		Hostname:  host,
		Timestamp: time.Now().UTC().Format(time.RFC3339),
		OS:        runtime.GOOS,
		Arch:      runtime.GOARCH,
		GoVersion: runtime.Version(),
		NumCPU:    runtime.NumCPU(),
		Memory: MemUsage{
			AllocBytes:      ms.Alloc,
			TotalAllocBytes: ms.TotalAlloc,
			SysBytes:        ms.Sys,
			HeapAllocBytes:  ms.HeapAlloc,
			HeapSysBytes:    ms.HeapSys,
			NumGC:           ms.NumGC,
		},
	}
	for _, w := range watches {
		u, err := walkUsage(w)
		if err != nil {
			return nil, err
		}
		rep.Paths = append(rep.Paths, u)
		rep.TotalBytes += u.Bytes
	}
	return rep, nil
}

// walkUsage measures one path's recursive on-disk usage. Entries that cannot
// be read are counted in Errors rather than aborting the walk.
func walkUsage(root string) (PathUsage, error) {
	abs, err := filepath.Abs(root)
	if err != nil {
		abs = root
	}
	u := PathUsage{Path: abs}

	info, err := os.Stat(abs)
	if err != nil {
		return u, fmt.Errorf("watch path %s: %w", root, err)
	}
	if !info.IsDir() {
		u.Files = 1
		u.Bytes = info.Size()
		return u, nil
	}
	err = filepath.WalkDir(abs, func(p string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			u.Errors++
			if p == abs {
				return walkErr
			}
			return nil
		}
		if d.IsDir() {
			if p != abs {
				u.Dirs++
			}
			return nil
		}
		fi, err := d.Info()
		if err != nil {
			u.Errors++
			return nil
		}
		if fi.Mode().IsRegular() {
			u.Files++
			u.Bytes += fi.Size()
		}
		return nil
	})
	if err != nil {
		return u, fmt.Errorf("watch path %s: %w", root, err)
	}
	return u, nil
}

// ---------------------------------------------------------------------------
// rollup
// ---------------------------------------------------------------------------

func cmdRollup(argv []string) {
	if hasHelp(argv) {
		usage(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{"dir": true, "stddev-threshold": true})

	fset := flag.NewFlagSet("rollup", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	threshold := fset.Float64("stddev-threshold", defaultStdDevT, "outlier cutoff in standard deviations")
	asJSON := fset.Bool("json", false, "emit JSON")
	var dirs multiFlag
	fset.Var(&dirs, "dir", "directory of *.json reports")
	if err := fset.Parse(argv); err != nil {
		fail("rollup: %v", err)
	}
	if math.IsNaN(*threshold) || math.IsInf(*threshold, 0) || *threshold < 0 {
		fail("rollup: --stddev-threshold must be a non-negative number (got %v)", *threshold)
	}

	files := append([]string{}, fset.Args()...)
	for _, d := range dirs {
		found, err := jsonFilesIn(d)
		if err != nil {
			errExit("rollup: %v", err)
		}
		files = append(files, found...)
	}
	files = dedupeFiles(files)
	if len(files) == 0 {
		errExit("rollup: no reports given; pass report files or --dir <reports-dir>")
	}

	rl := buildRollup(files, *threshold)
	if rl.ReportsIngested == 0 {
		for _, s := range rl.Skipped {
			fmt.Fprintf(os.Stderr, "%s: skipped %s: %s\n", appName, s.File, s.Reason)
		}
		errExit("rollup: no valid reports among the %d file(s) given", rl.FilesSeen)
	}

	if *asJSON {
		blob, err := json.MarshalIndent(rl, "", "  ")
		if err != nil {
			errExit("rollup: encoding: %v", err)
		}
		os.Stdout.Write(append(blob, '\n'))
		return
	}
	printRollup(os.Stdout, rl)
}

func jsonFilesIn(dir string) ([]string, error) {
	info, err := os.Stat(dir)
	if err != nil {
		return nil, fmt.Errorf("--dir %s: %w", dir, err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("--dir %s: not a directory", dir)
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, fmt.Errorf("--dir %s: %w", dir, err)
	}
	var out []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if strings.EqualFold(filepath.Ext(e.Name()), ".json") {
			out = append(out, filepath.Join(dir, e.Name()))
		}
	}
	sort.Strings(out)
	return out, nil
}

func dedupeFiles(in []string) []string {
	seen := map[string]bool{}
	var out []string
	for _, f := range in {
		key := f
		if abs, err := filepath.Abs(f); err == nil {
			key = abs
		}
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, f)
	}
	return out
}

// loadReport reads and validates a single report file.
func loadReport(path string) (*Report, error) {
	blob, err := os.ReadFile(path)
	if err != nil {
		return nil, errors.New(readableFSError(err))
	}
	if len(strings.TrimSpace(string(blob))) == 0 {
		return nil, errors.New("file is empty")
	}
	var rep Report
	dec := json.NewDecoder(strings.NewReader(string(blob)))
	if err := dec.Decode(&rep); err != nil {
		return nil, fmt.Errorf("invalid JSON: %v", err)
	}
	if strings.TrimSpace(rep.Machine) == "" {
		return nil, errors.New(`not a pchealth report: missing "machine" field`)
	}
	// A report may carry per-path usage, an explicit total, or both. Paths
	// are authoritative when present so hand-edited totals cannot drift.
	if len(rep.Paths) > 0 {
		var sum int64
		for _, p := range rep.Paths {
			if p.Bytes < 0 {
				return nil, fmt.Errorf("negative byte count for path %q", p.Path)
			}
			sum += p.Bytes
		}
		rep.TotalBytes = sum
	}
	if rep.TotalBytes < 0 {
		return nil, errors.New("negative total_bytes")
	}
	return &rep, nil
}

func readableFSError(err error) string {
	switch {
	case errors.Is(err, fs.ErrNotExist):
		return "file does not exist"
	case errors.Is(err, fs.ErrPermission):
		return "permission denied"
	default:
		return err.Error()
	}
}

func buildRollup(files []string, threshold float64) *Rollup {
	rl := &Rollup{
		Schema:          rollupSchema,
		Tool:            appName,
		Version:         appVersion,
		Generated:       time.Now().UTC().Format(time.RFC3339),
		StdDevThreshold: threshold,
		FilesSeen:       len(files),
		Machines:        []MachineRow{},
		Outliers:        []MachineRow{},
		Skipped:         []Skipped{},
		Superseded:      []Superseded{},
	}
	rl.Fleet.StdDevKind = stdDevKind

	byMachine := map[string]MachineRow{}
	var order []string
	for _, f := range files {
		rep, err := loadReport(f)
		if err != nil {
			rl.Skipped = append(rl.Skipped, Skipped{File: f, Reason: err.Error()})
			continue
		}
		rl.ReportsIngested++
		row := MachineRow{
			Machine:    rep.Machine,
			Hostname:   rep.Hostname,
			Timestamp:  rep.Timestamp,
			OS:         rep.OS,
			Arch:       rep.Arch,
			NumCPU:     rep.NumCPU,
			PathCount:  len(rep.Paths),
			TotalBytes: rep.TotalBytes,
			SourceFile: f,
		}
		prev, dup := byMachine[rep.Machine]
		if !dup {
			byMachine[rep.Machine] = row
			order = append(order, rep.Machine)
			continue
		}
		keep, drop := row, prev
		if prev.Timestamp > row.Timestamp {
			keep, drop = prev, row
		}
		byMachine[rep.Machine] = keep
		rl.Superseded = append(rl.Superseded, Superseded{
			Machine: rep.Machine,
			File:    drop.SourceFile,
			Reason:  fmt.Sprintf("duplicate report for machine %q; kept %s", rep.Machine, filepath.Base(keep.SourceFile)),
		})
	}

	rows := make([]MachineRow, 0, len(order))
	for _, name := range order {
		rows = append(rows, byMachine[name])
	}
	if len(rows) == 0 {
		return rl
	}

	values := make([]float64, len(rows))
	for i, r := range rows {
		values[i] = float64(r.TotalBytes)
		rl.Fleet.TotalBytes += r.TotalBytes
		rl.Fleet.TotalCPUs += r.NumCPU
	}
	rl.Fleet.MachineCount = len(rows)
	rl.Fleet.MeanBytes = mean(values)
	rl.Fleet.MedianBytes = median(values)
	rl.Fleet.StdDevBytes = populationStdDev(values)

	for i := range rows {
		if rl.Fleet.TotalBytes > 0 {
			rows[i].SharePct = float64(rows[i].TotalBytes) / float64(rl.Fleet.TotalBytes) * 100
		}
		// With a zero standard deviation every machine sits exactly on the
		// mean: the z-score is 0 and nothing can be an outlier. Guarding here
		// is what keeps the 1-machine and all-identical cases from dividing
		// by zero.
		if rl.Fleet.StdDevBytes > 0 {
			rows[i].ZScore = (float64(rows[i].TotalBytes) - rl.Fleet.MeanBytes) / rl.Fleet.StdDevBytes
			if math.Abs(rows[i].ZScore) > threshold {
				rows[i].Outlier = true
				rows[i].Direction = "high"
				if rows[i].ZScore < 0 {
					rows[i].Direction = "low"
				}
			}
		}
	}

	sort.SliceStable(rows, func(i, j int) bool {
		if rows[i].TotalBytes != rows[j].TotalBytes {
			return rows[i].TotalBytes > rows[j].TotalBytes
		}
		return rows[i].Machine < rows[j].Machine
	})
	rl.Machines = rows

	rl.Fleet.Min = Extreme{Machine: rows[len(rows)-1].Machine, Bytes: rows[len(rows)-1].TotalBytes}
	rl.Fleet.Max = Extreme{Machine: rows[0].Machine, Bytes: rows[0].TotalBytes}
	for _, r := range rows {
		if r.Outlier {
			rl.Outliers = append(rl.Outliers, r)
		}
	}
	return rl
}

func mean(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	var sum float64
	for _, x := range xs {
		sum += x
	}
	return sum / float64(len(xs))
}

func median(xs []float64) float64 {
	n := len(xs)
	if n == 0 {
		return 0
	}
	s := append([]float64(nil), xs...)
	sort.Float64s(s)
	if n%2 == 1 {
		return s[n/2]
	}
	return (s[n/2-1] + s[n/2]) / 2
}

// populationStdDev returns the POPULATION standard deviation: the square root
// of the mean squared deviation from the mean (divide by n, not n-1). The
// ingested reports are treated as the entire fleet, not a sample of it, so a
// single machine yields 0 rather than an undefined value.
func populationStdDev(xs []float64) float64 {
	n := len(xs)
	if n == 0 {
		return 0
	}
	m := mean(xs)
	var ss float64
	for _, x := range xs {
		d := x - m
		ss += d * d
	}
	return math.Sqrt(ss / float64(n))
}

// ---------------------------------------------------------------------------
// human output
// ---------------------------------------------------------------------------

func printRollup(w io.Writer, rl *Rollup) {
	fmt.Fprintf(w, "%s fleet rollup  (%s)\n", appName, rl.Generated)
	fmt.Fprintf(w, "  files seen %d, reports ingested %d, skipped %d, machines %d\n",
		rl.FilesSeen, rl.ReportsIngested, len(rl.Skipped), rl.Fleet.MachineCount)
	fmt.Fprintln(w)

	nameW := len("MACHINE")
	hostW := len("HOSTNAME")
	for _, r := range rl.Machines {
		nameW = max(nameW, len(r.Machine))
		hostW = max(hostW, len(r.Hostname))
	}
	header := fmt.Sprintf("%-*s  %-*s  %4s  %5s  %12s  %16s  %7s  %8s  %s",
		nameW, "MACHINE", hostW, "HOSTNAME", "CPUS", "PATHS",
		"DISK USAGE", "BYTES", "SHARE", "Z-SCORE", "FLAG")
	fmt.Fprintln(w, header)
	fmt.Fprintln(w, strings.Repeat("-", len(header)))
	for _, r := range rl.Machines {
		flagCol := ""
		if r.Outlier {
			flagCol = "** OUTLIER (" + r.Direction + ")"
		}
		fmt.Fprintf(w, "%-*s  %-*s  %4d  %5d  %12s  %16s  %6.2f%%  %+8.2f  %s\n",
			nameW, r.Machine, hostW, r.Hostname, r.NumCPU, r.PathCount,
			humanBytes(r.TotalBytes), comma(r.TotalBytes), r.SharePct, r.ZScore, flagCol)
	}

	f := rl.Fleet
	fmt.Fprintln(w)
	fmt.Fprintln(w, "FLEET TOTALS")
	const lw = 22
	fmt.Fprintf(w, "  %-*s%d\n", lw, "machines", f.MachineCount)
	fmt.Fprintf(w, "  %-*s%d\n", lw, "logical CPUs", f.TotalCPUs)
	fmt.Fprintf(w, "  %-*s%s  (%s bytes)\n", lw, "disk usage", humanBytes(f.TotalBytes), comma(f.TotalBytes))
	fmt.Fprintln(w)
	fmt.Fprintln(w, "USAGE PER MACHINE")
	fmt.Fprintf(w, "  %-*s%s  (%.4f bytes)\n", lw, "mean", humanFloatBytes(f.MeanBytes), f.MeanBytes)
	fmt.Fprintf(w, "  %-*s%s  (%.4f bytes)\n", lw, "median", humanFloatBytes(f.MedianBytes), f.MedianBytes)
	fmt.Fprintf(w, "  %-*s%s  (%.4f bytes)\n", lw, "std dev ("+f.StdDevKind+")",
		humanFloatBytes(f.StdDevBytes), f.StdDevBytes)
	fmt.Fprintf(w, "  %-*s%s  %s\n", lw, "min", f.Min.Machine, humanBytes(f.Min.Bytes))
	fmt.Fprintf(w, "  %-*s%s  %s\n", lw, "max", f.Max.Machine, humanBytes(f.Max.Bytes))

	fmt.Fprintln(w)
	fmt.Fprintf(w, "OUTLIERS  (further than %.2f %s standard deviation(s) from the fleet mean)\n",
		rl.StdDevThreshold, f.StdDevKind)
	switch {
	case f.StdDevBytes == 0 && f.MachineCount == 1:
		fmt.Fprintln(w, "  none - a single machine has no fleet to deviate from (std dev 0)")
	case f.StdDevBytes == 0:
		fmt.Fprintln(w, "  none - every machine reports identical usage (std dev 0)")
	case len(rl.Outliers) == 0:
		fmt.Fprintln(w, "  none - every machine is within the threshold")
	default:
		for _, r := range rl.Outliers {
			delta := float64(r.TotalBytes) - f.MeanBytes
			fmt.Fprintf(w, "  ** %s  %s  z=%+.2f  (%s the mean by %s)\n",
				r.Machine, humanBytes(r.TotalBytes), r.ZScore,
				map[bool]string{true: "above", false: "below"}[delta >= 0],
				humanFloatBytes(math.Abs(delta)))
		}
	}

	if len(rl.Superseded) > 0 {
		fmt.Fprintln(w)
		fmt.Fprintln(w, "SUPERSEDED REPORTS")
		for _, s := range rl.Superseded {
			fmt.Fprintf(w, "  %s: %s\n", s.File, s.Reason)
		}
	}
	if len(rl.Skipped) > 0 {
		fmt.Fprintln(w)
		fmt.Fprintf(w, "SKIPPED REPORTS (%d)\n", len(rl.Skipped))
		for _, s := range rl.Skipped {
			fmt.Fprintf(w, "  %s: %s\n", s.File, s.Reason)
		}
	}
}
