// Command backupmedic tracks the HEALTH OF BACKUP JOBS ACROSS A FLEET against an
// agreed recovery-point objective. Backup scripts append one immutable record per
// run to a shared history file; backupmedic then answers, per machine+job, when the
// job last succeeded, how far past the RPO it is, how long the current failure
// streak is, and whether the backup size has silently collapsed.
//
// It records what jobs REPORT. It cannot verify that a backup is restorable.
package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"text/tabwriter"
	"time"
)

const version = "1.0.0"

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

// Run statuses a backup job may report.
const (
	statusOK      = "ok"
	statusFailed  = "failed"
	statusPartial = "partial"
)

var knownStatuses = []string{statusOK, statusFailed, statusPartial}

// Verdicts, worst first.
const (
	verdictNever   = "NEVER-SUCCEEDED"
	verdictFailing = "FAILING"
	verdictLate    = "LATE"
	verdictShrink  = "SILENT-SHRINK"
	verdictHealthy = "HEALTHY"
)

var verdictOrder = []string{
	verdictNever, verdictFailing, verdictLate, verdictShrink, verdictHealthy,
}

const reportNote = "BackupMedic reports what backup jobs said about themselves. A job can report ok and still be unrestorable; pair this with a real restore test."

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, `backupmedic %s - fleet backup-job health against an agreed RPO

BackupMedic does not make backups and does not look at your files. Backup jobs
append one record per run to a shared history file; BackupMedic answers, for
every machine and job: when did it LAST SUCCEED, is that inside the recovery
point objective we promised, is it failing silently, and has its output
suddenly shrunk?

USAGE
  backupmedic record  --machine <name> --job <name> --status ok|failed|partial
                      --bytes <n> --history <history.jsonl>
                      [--started <RFC3339>] [--finished <RFC3339>] [--note "..."] [--json]
  backupmedic health  --history <file> [--rpo 24h] [--window 10]
                      [--size-drop-pct 40] [--fail-streak 1] [--asof <RFC3339>] [--json]
  backupmedic history --history <file> [--machine X] [--job Y] [--last N] [--json]
  backupmedic summary --history <file> [--rpo 24h] [--asof <RFC3339>] [--json]
  backupmedic help | -h | --help
  backupmedic version

COMMANDS
  record   Append one immutable run record. This is what a backup script calls
           when it finishes. The only command that writes anything, and it only
           ever appends to --history.
  health   Per machine+job: last successful run and its age, whether the RPO is
           breached, the current consecutive-failure streak, the success rate
           over the last --window runs, and the trend in backup size.
  history  The raw run records, filterable by machine, job and recency.
  summary  The one-screen fleet answer: healthy vs breaching, worst offender,
           and total protected bytes across the fleet.

FLAGS
  --history PATH      Shared JSONL history file. Required by every command.
  --machine NAME      Machine the job ran on.
  --job NAME          Job name, unique per machine.
  --status S          ok, failed or partial (record).
  --bytes N           Bytes this run wrote or copied (record, required).
  --started TS        RFC3339 start time. Default: --finished.
  --finished TS       RFC3339 finish time. Default: now.
  --note TEXT         Free text stored with the run.
  --rpo D             Recovery point objective, e.g. 24h, 90m, 168h. Default 24h.
  --window N          Runs to average the success rate over. Default 10.
  --size-drop-pct P   Flag SILENT-SHRINK when the newest successful run is more
                      than P%% smaller than the previous one. Default 40.
  --fail-streak N     Consecutive non-ok runs before a job counts as FAILING.
                      Default 1 (the last run did not fully succeed).
  --asof TS           Evaluate the fleet as of this RFC3339 instant instead of
                      now, so a report can be reproduced later. Reporting only:
                      records after this instant are excluded.
  --last N            history: show only the N most recent matching runs.
  --json              Machine-readable report on stdout (every command).

HISTORY FORMAT
  One JSON object per line, append only:
  {"machine":"nas01","job":"nightly","status":"ok","bytes":83886080,
   "started":"2026-08-11T01:00:00Z","finished":"2026-08-11T01:12:00Z","note":""}
  Timestamps keep the offset they were written with; instants are compared, so
  2026-08-11T03:00:00+02:00 and 2026-08-11T01:00:00Z are the same moment.
  Lines that are blank, unparseable or invalid are skipped with a reason; every
  other run in the file is still evaluated.

VERDICTS (worst first)
  NEVER-SUCCEEDED  The job has run but has never reported ok. There is nothing
                   to restore from.
  FAILING          The last --fail-streak runs did not fully succeed. A partial
                   run counts as not succeeded.
  LATE             The last success is older than the RPO. The promise is broken.
  SILENT-SHRINK    The newest successful run is more than --size-drop-pct
                   smaller than the one before it: the classic silent failure.
  HEALTHY          Succeeded inside the RPO, no failure streak, no size collapse.

WHAT THIS CANNOT DO
  BackupMedic believes the job. It never opens a backup, never hashes a file and
  never attempts a restore, so a job that reports ok while writing garbage is
  reported HEALTHY. Use it to catch jobs that stopped working, not to prove that
  a backup works. Records are read from a shared file; nothing is sent anywhere.

EXIT CODES
  0  every job is HEALTHY (or the command completed with nothing to judge)
  1  usage error, unreadable history, bad flag value
  2  at least one job is not HEALTHY

EXAMPLES
  backupmedic record --machine nas01 --job nightly --status ok --bytes 83886080 \
      --history /srv/backup/history.jsonl
  backupmedic health  --history /srv/backup/history.jsonl --rpo 24h
  backupmedic health  --history /srv/backup/history.jsonl --rpo 168h --json
  backupmedic history --history /srv/backup/history.jsonl --machine nas01 --last 5
  backupmedic summary --history /srv/backup/history.jsonl
`, version)
}

// ---------------------------------------------------------------------------
// History records
// ---------------------------------------------------------------------------

// record is exactly what one line of the history file holds.
type record struct {
	Machine  string `json:"machine"`
	Job      string `json:"job"`
	Status   string `json:"status"`
	Bytes    int64  `json:"bytes"`
	Started  string `json:"started"`
	Finished string `json:"finished"`
	Note     string `json:"note,omitempty"`
}

// loaded is a record plus where it came from and its parsed instants.
type loaded struct {
	record
	Line     int    `json:"line"`
	Duration string `json:"duration"`

	started  time.Time
	finished time.Time
}

type skippedLine struct {
	Line   int    `json:"line"`
	Reason string `json:"reason"`
	Text   string `json:"text"`
}

func parseStamp(field, s string) (time.Time, error) {
	t, err := time.Parse(time.RFC3339, s)
	if err != nil {
		return time.Time{}, fmt.Errorf("%s %q is not RFC3339 (want 2026-08-11T01:00:00Z or 2026-08-11T03:00:00+02:00)", field, s)
	}
	return t, nil
}

func validStatus(s string) bool {
	for _, k := range knownStatuses {
		if s == k {
			return true
		}
	}
	return false
}

// validate turns a raw record into a loaded one, or explains why it cannot.
func validate(r record, line int) (loaded, error) {
	l := loaded{record: r, Line: line}
	if strings.TrimSpace(r.Machine) == "" {
		return l, fmt.Errorf(`"machine" is missing or empty`)
	}
	if strings.TrimSpace(r.Job) == "" {
		return l, fmt.Errorf(`"job" is missing or empty`)
	}
	if !validStatus(r.Status) {
		return l, fmt.Errorf(`"status" is %q; expected one of %s`, r.Status, strings.Join(knownStatuses, ", "))
	}
	if r.Bytes < 0 {
		return l, fmt.Errorf(`"bytes" is %d; must not be negative`, r.Bytes)
	}
	st, err := parseStamp("started", r.Started)
	if err != nil {
		return l, err
	}
	fi, err := parseStamp("finished", r.Finished)
	if err != nil {
		return l, err
	}
	if fi.Before(st) {
		return l, fmt.Errorf("finished %s is before started %s", r.Finished, r.Started)
	}
	l.started, l.finished = st, fi
	l.Duration = humanDur(fi.Sub(st))
	return l, nil
}

const maxLineBytes = 4 << 20

// loadHistory reads the append-only file. It never writes to it.
func loadHistory(path string) ([]loaded, []skippedLine, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, nil, fmt.Errorf("cannot read history: %v", err)
	}
	defer f.Close()
	info, err := f.Stat()
	if err != nil {
		return nil, nil, fmt.Errorf("cannot read history: %v", err)
	}
	if info.IsDir() {
		return nil, nil, fmt.Errorf("history %s is a directory, not a file", path)
	}

	var runs []loaded
	var skipped []skippedLine
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
	line := 0
	for sc.Scan() {
		line++
		text := strings.TrimSpace(sc.Text())
		if text == "" {
			continue
		}
		var r record
		dec := json.NewDecoder(strings.NewReader(text))
		dec.DisallowUnknownFields()
		if err := dec.Decode(&r); err != nil {
			skipped = append(skipped, skippedLine{Line: line, Reason: "not a valid run record: " + err.Error(), Text: clip(text)})
			continue
		}
		l, verr := validate(r, line)
		if verr != nil {
			skipped = append(skipped, skippedLine{Line: line, Reason: verr.Error(), Text: clip(text)})
			continue
		}
		runs = append(runs, l)
	}
	if err := sc.Err(); err != nil {
		return nil, nil, fmt.Errorf("reading history %s: %v", path, err)
	}
	sort.SliceStable(runs, func(i, j int) bool { return runs[i].finished.Before(runs[j].finished) })
	return runs, skipped, nil
}

func clip(s string) string {
	const max = 80
	if len(s) <= max {
		return s
	}
	return s[:max] + "..."
}

func humanDur(d time.Duration) string {
	if d < 0 {
		return "-" + humanDur(-d)
	}
	d = d.Round(time.Second)
	days := int64(d / (24 * time.Hour))
	d -= time.Duration(days) * 24 * time.Hour
	h := int64(d / time.Hour)
	d -= time.Duration(h) * time.Hour
	m := int64(d / time.Minute)
	d -= time.Duration(m) * time.Minute
	s := int64(d / time.Second)
	switch {
	case days > 0:
		return fmt.Sprintf("%dd %dh %dm", days, h, m)
	case h > 0:
		return fmt.Sprintf("%dh %dm", h, m)
	case m > 0:
		return fmt.Sprintf("%dm %ds", m, s)
	default:
		return fmt.Sprintf("%ds", s)
	}
}

// ---------------------------------------------------------------------------
// Health model
// ---------------------------------------------------------------------------

type policy struct {
	RPO          string  `json:"rpo"`
	RPOSeconds   int64   `json:"rpo_seconds"`
	Window       int     `json:"window"`
	SizeDropPct  float64 `json:"size_drop_pct"`
	FailStreak   int     `json:"fail_streak"`
	AsOf         string  `json:"as_of"`
	AsOfExplicit bool    `json:"as_of_explicit"`
}

type jobHealth struct {
	Machine string `json:"machine"`
	Job     string `json:"job"`
	Runs    int    `json:"runs"`

	LastRun       string `json:"last_run"`
	LastStatus    string `json:"last_status"`
	LastSuccess   string `json:"last_success"`
	AgeSeconds    int64  `json:"last_success_age_seconds"`
	Age           string `json:"last_success_age"`
	RPOBreached   bool   `json:"rpo_breached"`
	OverRPOBySecs int64  `json:"over_rpo_by_seconds"`

	FailStreak     int      `json:"fail_streak"`
	Successes      int      `json:"successes"`
	WindowRuns     int      `json:"window_runs"`
	WindowOK       int      `json:"window_ok"`
	SuccessRate    float64  `json:"success_rate_pct"`
	LastBytes      int64    `json:"last_success_bytes"`
	PrevBytes      int64    `json:"prev_success_bytes"`
	SizeChangePct  float64  `json:"size_change_pct"`
	SizeDropPct    float64  `json:"size_drop_pct"`
	SizeComparable bool     `json:"size_comparable"`
	Verdict        string   `json:"verdict"`
	Reasons        []string `json:"reasons"`
}

type verdictCount struct {
	Verdict string `json:"verdict"`
	Jobs    int    `json:"jobs"`
}

type totals struct {
	Machines          int            `json:"machines"`
	Jobs              int            `json:"jobs"`
	Runs              int            `json:"runs"`
	Healthy           int            `json:"healthy_jobs"`
	Breaching         int            `json:"breaching_jobs"`
	ByVerdict         []verdictCount `json:"by_verdict"`
	ProtectedBytes    int64          `json:"protected_bytes"`
	ProtectedBytesHum string         `json:"protected_bytes_human"`
}

type healthReport struct {
	Tool     string        `json:"tool"`
	Version  string        `json:"version"`
	Command  string        `json:"command"`
	ReadOnly bool          `json:"read_only"`
	History  string        `json:"history"`
	Policy   policy        `json:"policy"`
	Jobs     []jobHealth   `json:"jobs"`
	Worst    *jobHealth    `json:"worst_offender,omitempty"`
	Totals   totals        `json:"totals"`
	Skipped  []skippedLine `json:"skipped_lines"`
	ExitCode int           `json:"exit_code"`
	Note     string        `json:"note"`
}

type historyReport struct {
	Tool     string        `json:"tool"`
	Version  string        `json:"version"`
	Command  string        `json:"command"`
	ReadOnly bool          `json:"read_only"`
	History  string        `json:"history"`
	Filter   filterSpec    `json:"filter"`
	Matched  int           `json:"matched"`
	Shown    int           `json:"shown"`
	Runs     []loaded      `json:"runs"`
	Skipped  []skippedLine `json:"skipped_lines"`
	ExitCode int           `json:"exit_code"`
	Note     string        `json:"note"`
}

type filterSpec struct {
	Machine string `json:"machine"`
	Job     string `json:"job"`
	Last    int    `json:"last"`
}

// evaluate applies the policy to every machine+job in the history.
func evaluate(runs []loaded, p policy, asOf time.Time, rpo time.Duration) []jobHealth {
	type key struct{ machine, job string }
	order := []key{}
	groups := map[key][]loaded{}
	for _, r := range runs {
		k := key{r.Machine, r.Job}
		if _, ok := groups[k]; !ok {
			order = append(order, k)
		}
		groups[k] = append(groups[k], r)
	}
	sort.Slice(order, func(i, j int) bool {
		if order[i].machine != order[j].machine {
			return order[i].machine < order[j].machine
		}
		return order[i].job < order[j].job
	})

	out := make([]jobHealth, 0, len(order))
	for _, k := range order {
		g := groups[k]
		h := jobHealth{Machine: k.machine, Job: k.job, Runs: len(g), Reasons: []string{}}
		last := g[len(g)-1]
		h.LastRun = last.Finished
		h.LastStatus = last.Status

		// Successes, newest last.
		var succ []loaded
		for _, r := range g {
			if r.Status == statusOK {
				succ = append(succ, r)
			}
		}
		h.Successes = len(succ)

		// Trailing streak of runs that did not fully succeed.
		for i := len(g) - 1; i >= 0; i-- {
			if g[i].Status == statusOK {
				break
			}
			h.FailStreak++
		}

		// Success rate over the last --window runs.
		start := len(g) - p.Window
		if start < 0 {
			start = 0
		}
		win := g[start:]
		h.WindowRuns = len(win)
		for _, r := range win {
			if r.Status == statusOK {
				h.WindowOK++
			}
		}
		if h.WindowRuns > 0 {
			h.SuccessRate = float64(h.WindowOK) * 100 / float64(h.WindowRuns)
		}

		// Size trend across the two most recent successful runs.
		if len(succ) >= 1 {
			h.LastBytes = succ[len(succ)-1].Bytes
		}
		if len(succ) >= 2 {
			h.PrevBytes = succ[len(succ)-2].Bytes
			if h.PrevBytes > 0 {
				h.SizeComparable = true
				h.SizeChangePct = float64(h.LastBytes-h.PrevBytes) * 100 / float64(h.PrevBytes)
				h.SizeDropPct = -h.SizeChangePct
			}
		}

		// RPO.
		if len(succ) > 0 {
			ls := succ[len(succ)-1]
			h.LastSuccess = ls.Finished
			age := asOf.Sub(ls.finished)
			h.AgeSeconds = int64(age / time.Second)
			h.Age = humanDur(age)
			if age > rpo {
				h.RPOBreached = true
				h.OverRPOBySecs = int64((age - rpo) / time.Second)
			}
		} else {
			h.Age = "never"
		}

		// Reasons, always all of them, so nothing hides behind the verdict.
		if len(succ) == 0 {
			h.Reasons = append(h.Reasons, fmt.Sprintf("no successful run in %d recorded run(s); nothing to restore from", len(g)))
		}
		if h.FailStreak >= p.FailStreak && h.FailStreak > 0 {
			h.Reasons = append(h.Reasons, fmt.Sprintf("last %d run(s) did not succeed (most recent: %s at %s)",
				h.FailStreak, last.Status, last.Finished))
		}
		if h.RPOBreached {
			h.Reasons = append(h.Reasons, fmt.Sprintf("last success %s ago, RPO is %s: over by %s",
				h.Age, p.RPO, humanDur(time.Duration(h.OverRPOBySecs)*time.Second)))
		}
		if h.SizeComparable && h.SizeDropPct > p.SizeDropPct {
			h.Reasons = append(h.Reasons, fmt.Sprintf("newest successful run is %.1f%% smaller than the previous one (%s -> %s), limit %.1f%%",
				h.SizeDropPct, humanBytes(h.PrevBytes), humanBytes(h.LastBytes), p.SizeDropPct))
		}

		switch {
		case len(succ) == 0:
			h.Verdict = verdictNever
		case h.FailStreak > 0 && h.FailStreak >= p.FailStreak:
			h.Verdict = verdictFailing
		case h.RPOBreached:
			h.Verdict = verdictLate
		case h.SizeComparable && h.SizeDropPct > p.SizeDropPct:
			h.Verdict = verdictShrink
		default:
			h.Verdict = verdictHealthy
		}
		out = append(out, h)
	}
	return out
}

func verdictRank(v string) int {
	for i, k := range verdictOrder {
		if k == v {
			return i
		}
	}
	return len(verdictOrder)
}

func computeTotals(jobs []jobHealth, runs int) totals {
	t := totals{Jobs: len(jobs), Runs: runs}
	seenMachine := map[string]bool{}
	counts := map[string]int{}
	for _, j := range jobs {
		if !seenMachine[j.Machine] {
			seenMachine[j.Machine] = true
			t.Machines++
		}
		counts[j.Verdict]++
		if j.Verdict == verdictHealthy {
			t.Healthy++
		} else {
			t.Breaching++
		}
		t.ProtectedBytes += j.LastBytes
	}
	t.ByVerdict = []verdictCount{}
	for _, v := range verdictOrder {
		if counts[v] > 0 {
			t.ByVerdict = append(t.ByVerdict, verdictCount{Verdict: v, Jobs: counts[v]})
		}
	}
	t.ProtectedBytesHum = humanBytes(t.ProtectedBytes)
	return t
}

// worstOffender picks the job a team lead should look at first: worst verdict,
// then the longest time since a successful run.
func worstOffender(jobs []jobHealth) *jobHealth {
	var best *jobHealth
	for i := range jobs {
		j := &jobs[i]
		if j.Verdict == verdictHealthy {
			continue
		}
		if best == nil {
			best = j
			continue
		}
		bi, bj := verdictRank(j.Verdict), verdictRank(best.Verdict)
		switch {
		case bi != bj:
			if bi < bj {
				best = j
			}
		case j.LastSuccess == "" && best.LastSuccess != "":
			best = j
		case j.LastSuccess != "" && best.LastSuccess == "":
		case j.AgeSeconds != best.AgeSeconds:
			if j.AgeSeconds > best.AgeSeconds {
				best = j
			}
		}
	}
	return best
}

// ---------------------------------------------------------------------------
// 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("backupmedic %s\n", version)
		os.Exit(exitOK)
	}
	switch args[0] {
	case "record":
		os.Exit(runRecord(args[1:]))
	case "health":
		os.Exit(runHealth(args[1:]))
	case "history":
		os.Exit(runHistoryCmd(args[1:]))
	case "summary":
		os.Exit(runSummary(args[1:]))
	default:
		fmt.Fprintf(os.Stderr, "backupmedic: 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, "backupmedic: "+format+"\n\n", a...)
	usage(os.Stderr)
	return exitUsage
}

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

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

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

// --- record ----------------------------------------------------------------

func runRecord(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("record", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	machine := fset.String("machine", "", "machine name")
	job := fset.String("job", "", "job name")
	status := fset.String("status", "", "ok|failed|partial")
	bytesN := fset.Int64("bytes", 0, "bytes written by this run")
	started := fset.String("started", "", "RFC3339 start time")
	finished := fset.String("finished", "", "RFC3339 finish time")
	note := fset.String("note", "", "free text")
	histPath := fset.String("history", "", "history file")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{
		"machine": true, "job": true, "status": true, "bytes": true,
		"started": true, "finished": true, "note": true, "history": true,
	})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("record takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	set := map[string]bool{}
	fset.Visit(func(f *flag.Flag) { set[f.Name] = true })

	if strings.TrimSpace(*histPath) == "" {
		return fail("record needs --history <history.jsonl>")
	}
	if strings.TrimSpace(*machine) == "" {
		return fail("record needs --machine <name>")
	}
	if strings.TrimSpace(*job) == "" {
		return fail("record needs --job <name>")
	}
	if !validStatus(*status) {
		return fail("--status is %q; expected one of %s", *status, strings.Join(knownStatuses, ", "))
	}
	if !set["bytes"] {
		return fail("record needs --bytes <n> (use 0 when the run wrote nothing)")
	}
	if *bytesN < 0 {
		return fail("--bytes is %d; must not be negative", *bytesN)
	}

	// Check the timestamps the user actually typed, so the error names their flag.
	for name, val := range map[string]string{"--started": *started, "--finished": *finished} {
		if v := strings.TrimSpace(val); v != "" {
			if _, err := parseStamp(name, v); err != nil {
				return failPlain("%v", err)
			}
		}
	}
	now := time.Now().Format(time.RFC3339)
	fin := strings.TrimSpace(*finished)
	if fin == "" {
		fin = now
	}
	st := strings.TrimSpace(*started)
	if st == "" {
		st = fin
	}
	rec := record{
		Machine: strings.TrimSpace(*machine), Job: strings.TrimSpace(*job),
		Status: *status, Bytes: *bytesN, Started: st, Finished: fin,
		Note: strings.TrimSpace(*note),
	}
	l, err := validate(rec, 0)
	if err != nil {
		return failPlain("%v", err)
	}
	// Store the instants normalised but with their original offset preserved.
	rec.Started = l.started.Format(time.RFC3339)
	rec.Finished = l.finished.Format(time.RFC3339)

	line, err := json.Marshal(rec)
	if err != nil {
		return failPlain("cannot encode record: %v", err)
	}
	if err := appendLine(*histPath, string(line)); err != nil {
		return failPlain("%v", err)
	}
	if *asJSON {
		return emitJSON(recordReport{
			Tool: "backupmedic", Version: version, Command: "record", Appended: true,
			History: absOr(*histPath), Run: rec,
			Duration: humanDur(l.finished.Sub(l.started)),
			ExitCode: exitOK, Note: reportNote,
		}, exitOK)
	}
	fmt.Printf("recorded %s/%s %s %s at %s (duration %s) -> %s\n",
		rec.Machine, rec.Job, strings.ToUpper(rec.Status), humanBytes(rec.Bytes),
		rec.Finished, humanDur(l.finished.Sub(l.started)), absOr(*histPath))
	return exitOK
}

// recordReport is the --json form of what record appended.
type recordReport struct {
	Tool     string `json:"tool"`
	Version  string `json:"version"`
	Command  string `json:"command"`
	Appended bool   `json:"appended"`
	History  string `json:"history"`
	Run      record `json:"run"`
	Duration string `json:"duration"`
	ExitCode int    `json:"exit_code"`
	Note     string `json:"note"`
}

// appendLine only ever appends. Existing bytes are never rewritten; if the file
// does not end in a newline a newline is appended first so the last record stays
// a whole line.
func appendLine(path, line string) error {
	if info, err := os.Stat(path); err == nil {
		if info.IsDir() {
			return fmt.Errorf("history %s is a directory, not a file", path)
		}
		if info.Size() > 0 {
			f, err := os.Open(path)
			if err != nil {
				return fmt.Errorf("cannot read history %s: %v", path, err)
			}
			buf := make([]byte, 1)
			_, rerr := f.ReadAt(buf, info.Size()-1)
			f.Close()
			if rerr != nil {
				return fmt.Errorf("cannot read history %s: %v", path, rerr)
			}
			if buf[0] != '\n' {
				line = "\n" + line
			}
		}
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("cannot use history %s: %v", path, err)
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot append to history %s: %v", path, err)
	}
	if _, err := f.WriteString(line + "\n"); err != nil {
		f.Close()
		return fmt.Errorf("cannot append to history %s: %v", path, err)
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return fmt.Errorf("cannot flush history %s: %v", path, err)
	}
	return f.Close()
}

// --- shared policy parsing --------------------------------------------------

type healthOpts struct {
	histPath    string
	rpo         string
	window      int
	sizeDropPct float64
	failStreak  int
	asof        string
}

func buildReport(o healthOpts, command string) (*healthReport, int) {
	if strings.TrimSpace(o.histPath) == "" {
		return nil, fail("%s needs --history <history.jsonl>", command)
	}
	rpo, err := time.ParseDuration(o.rpo)
	if err != nil {
		return nil, fail("--rpo %q is not a duration like 24h, 90m or 168h", o.rpo)
	}
	if rpo <= 0 {
		return nil, fail("--rpo %s must be greater than zero", o.rpo)
	}
	if o.window < 1 {
		return nil, fail("--window must be at least 1 (got %d)", o.window)
	}
	if o.sizeDropPct < 0 || o.sizeDropPct > 100 {
		return nil, fail("--size-drop-pct must be between 0 and 100 (got %g)", o.sizeDropPct)
	}
	if o.failStreak < 1 {
		return nil, fail("--fail-streak must be at least 1 (got %d)", o.failStreak)
	}
	asOf := time.Now()
	explicit := strings.TrimSpace(o.asof) != ""
	if explicit {
		t, err := parseStamp("--asof", strings.TrimSpace(o.asof))
		if err != nil {
			return nil, fail("%v", err)
		}
		asOf = t
	}
	runs, skipped, err := loadHistory(o.histPath)
	if err != nil {
		return nil, failPlain("%v", err)
	}
	if explicit {
		kept := runs[:0]
		for _, r := range runs {
			if !r.finished.After(asOf) {
				kept = append(kept, r)
			}
		}
		runs = kept
	}
	p := policy{
		RPO: rpo.String(), RPOSeconds: int64(rpo / time.Second),
		Window: o.window, SizeDropPct: o.sizeDropPct, FailStreak: o.failStreak,
		AsOf: asOf.Format(time.RFC3339), AsOfExplicit: explicit,
	}
	jobs := evaluate(runs, p, asOf, rpo)
	rep := &healthReport{
		Tool: "backupmedic", Version: version, Command: command, ReadOnly: true,
		History: absOr(o.histPath), Policy: p, Jobs: jobs,
		Totals: computeTotals(jobs, len(runs)), Note: reportNote,
	}
	rep.Skipped = skipped
	if rep.Skipped == nil {
		rep.Skipped = []skippedLine{}
	}
	rep.Worst = worstOffender(jobs)
	rep.ExitCode = exitOK
	if rep.Totals.Breaching > 0 {
		rep.ExitCode = exitUnhealthy
	}
	return rep, rep.ExitCode
}

// --- health -----------------------------------------------------------------

func runHealth(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("health", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	var o healthOpts
	fset.StringVar(&o.histPath, "history", "", "history file")
	fset.StringVar(&o.rpo, "rpo", "24h", "recovery point objective")
	fset.IntVar(&o.window, "window", 10, "runs to compute the success rate over")
	fset.Float64Var(&o.sizeDropPct, "size-drop-pct", 40, "size drop that means SILENT-SHRINK")
	fset.IntVar(&o.failStreak, "fail-streak", 1, "consecutive non-ok runs that mean FAILING")
	fset.StringVar(&o.asof, "asof", "", "evaluate as of this RFC3339 instant")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{
		"history": true, "rpo": true, "window": true,
		"size-drop-pct": true, "fail-streak": true, "asof": true,
	})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("health takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	rep, code := buildReport(o, "health")
	if rep == nil {
		return code
	}
	if *asJSON {
		return emitJSON(rep, rep.ExitCode)
	}
	printHealth(os.Stdout, rep)
	return rep.ExitCode
}

// --- summary ----------------------------------------------------------------

func runSummary(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("summary", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	var o healthOpts
	fset.StringVar(&o.histPath, "history", "", "history file")
	fset.StringVar(&o.rpo, "rpo", "24h", "recovery point objective")
	fset.IntVar(&o.window, "window", 10, "runs to compute the success rate over")
	fset.Float64Var(&o.sizeDropPct, "size-drop-pct", 40, "size drop that means SILENT-SHRINK")
	fset.IntVar(&o.failStreak, "fail-streak", 1, "consecutive non-ok runs that mean FAILING")
	fset.StringVar(&o.asof, "asof", "", "evaluate as of this RFC3339 instant")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{
		"history": true, "rpo": true, "window": true,
		"size-drop-pct": true, "fail-streak": true, "asof": true,
	})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("summary takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	rep, code := buildReport(o, "summary")
	if rep == nil {
		return code
	}
	if *asJSON {
		return emitJSON(rep, rep.ExitCode)
	}
	printSummary(os.Stdout, rep)
	return rep.ExitCode
}

// --- history ----------------------------------------------------------------

func runHistoryCmd(args []string) int {
	if helpRequested(args) {
		usage(os.Stdout)
		return exitOK
	}
	fset := flag.NewFlagSet("history", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	histPath := fset.String("history", "", "history file")
	machine := fset.String("machine", "", "only this machine")
	job := fset.String("job", "", "only this job")
	last := fset.Int("last", 0, "only the N most recent matching runs")
	asJSON := fset.Bool("json", false, "machine-readable output")
	args = reorderFlags(args, map[string]bool{
		"history": true, "machine": true, "job": true, "last": true,
	})
	if err := fset.Parse(args); err != nil {
		return fail("%v", err)
	}
	if rest := fset.Args(); len(rest) > 0 {
		return fail("history takes no positional arguments (got %s)", strings.Join(rest, ", "))
	}
	if strings.TrimSpace(*histPath) == "" {
		return fail("history needs --history <history.jsonl>")
	}
	if *last < 0 {
		return fail("--last must not be negative (got %d)", *last)
	}
	runs, skipped, err := loadHistory(*histPath)
	if err != nil {
		return failPlain("%v", err)
	}
	wantM, wantJ := strings.TrimSpace(*machine), strings.TrimSpace(*job)
	matched := []loaded{}
	for _, r := range runs {
		if wantM != "" && r.Machine != wantM {
			continue
		}
		if wantJ != "" && r.Job != wantJ {
			continue
		}
		matched = append(matched, r)
	}
	shown := matched
	if *last > 0 && len(shown) > *last {
		shown = shown[len(shown)-*last:]
	}
	rep := &historyReport{
		Tool: "backupmedic", Version: version, Command: "history", ReadOnly: true,
		History: absOr(*histPath),
		Filter:  filterSpec{Machine: wantM, Job: wantJ, Last: *last},
		Matched: len(matched), Shown: len(shown), Runs: shown,
		Skipped: skipped, ExitCode: exitOK, Note: reportNote,
	}
	if rep.Skipped == nil {
		rep.Skipped = []skippedLine{}
	}
	if rep.Runs == nil {
		rep.Runs = []loaded{}
	}
	if *asJSON {
		return emitJSON(rep, exitOK)
	}
	printHistory(os.Stdout, rep)
	return exitOK
}

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

func printPolicy(w io.Writer, rep *healthReport) {
	p := rep.Policy
	asof := p.AsOf
	if !p.AsOfExplicit {
		asof += "  (now)"
	} else {
		asof += "  (--asof; later runs excluded)"
	}
	fmt.Fprintf(w, "history:   %s\n", rep.History)
	fmt.Fprintf(w, "as of:     %s\n", asof)
	fmt.Fprintf(w, "policy:    rpo %s, success rate over last %d runs, silent-shrink > %.1f%%, failing after %d non-ok run(s)\n",
		p.RPO, p.Window, p.SizeDropPct, p.FailStreak)
	fmt.Fprintf(w, "scope:     %d run(s), %d job(s) on %d machine(s)\n",
		rep.Totals.Runs, rep.Totals.Jobs, rep.Totals.Machines)
}

func trendCell(j jobHealth) string {
	if !j.SizeComparable {
		return "-"
	}
	return fmt.Sprintf("%+.1f%%", j.SizeChangePct)
}

func printSkipped(w io.Writer, skipped []skippedLine) {
	if len(skipped) == 0 {
		return
	}
	fmt.Fprintf(w, "SKIPPED LINES (%d) - the rest of the history was still evaluated\n", len(skipped))
	for _, s := range skipped {
		fmt.Fprintf(w, "  line %d: %s\n", s.Line, s.Reason)
		fmt.Fprintf(w, "           %s\n", s.Text)
	}
	fmt.Fprintln(w)
}

func printHealth(w io.Writer, rep *healthReport) {
	fmt.Fprintf(w, "backupmedic %s  HEALTH (read-only)\n", version)
	printPolicy(w, rep)
	fmt.Fprintln(w)
	printSkipped(w, rep.Skipped)

	if len(rep.Jobs) == 0 {
		fmt.Fprintf(w, "no runs recorded: there is nothing to judge yet.\n")
		fmt.Fprintf(w, "RESULT: 0 jobs known (exit %d)\n", rep.ExitCode)
		fmt.Fprintf(w, "note: %s\n", reportNote)
		return
	}

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "MACHINE\tJOB\tVERDICT\tLAST SUCCESS\tAGE\tRPO\tSTREAK\tLAST\tRUNS\tSUCCESS\tSIZE\tTREND")
	for _, j := range rep.Jobs {
		ls := j.LastSuccess
		if ls == "" {
			ls = "never"
		}
		rpoCell := "ok"
		if j.RPOBreached {
			rpoCell = "BREACH"
		}
		if j.LastSuccess == "" {
			rpoCell = "n/a"
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%d\t%d/%d (%.0f%%)\t%s\t%s\n",
			j.Machine, j.Job, j.Verdict, ls, j.Age, rpoCell, j.FailStreak,
			strings.ToUpper(j.LastStatus), j.Runs,
			j.WindowOK, j.WindowRuns, j.SuccessRate,
			humanBytes(j.LastBytes), trendCell(j))
	}
	tw.Flush()
	fmt.Fprintln(w)

	if rep.Totals.Breaching > 0 {
		fmt.Fprintf(w, "NOT HEALTHY (%d)\n", rep.Totals.Breaching)
		ordered := make([]jobHealth, len(rep.Jobs))
		copy(ordered, rep.Jobs)
		sort.SliceStable(ordered, func(a, b int) bool {
			return verdictRank(ordered[a].Verdict) < verdictRank(ordered[b].Verdict)
		})
		for _, j := range ordered {
			if j.Verdict == verdictHealthy {
				continue
			}
			fmt.Fprintf(w, "  %-16s %s/%s\n", j.Verdict, j.Machine, j.Job)
			for _, r := range j.Reasons {
				fmt.Fprintf(w, "      - %s\n", r)
			}
		}
		fmt.Fprintln(w)
	}

	tw = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "VERDICT\tJOBS")
	for _, vc := range rep.Totals.ByVerdict {
		fmt.Fprintf(tw, "%s\t%d\n", vc.Verdict, vc.Jobs)
	}
	tw.Flush()
	fmt.Fprintln(w)

	fmt.Fprintf(w, "protected: %s in the most recent successful run of each job (%d bytes)\n",
		rep.Totals.ProtectedBytesHum, rep.Totals.ProtectedBytes)
	if rep.ExitCode == exitOK {
		fmt.Fprintf(w, "RESULT: all %d job(s) HEALTHY within the %s RPO (exit %d)\n",
			rep.Totals.Jobs, rep.Policy.RPO, exitOK)
	} else {
		fmt.Fprintf(w, "RESULT: %d of %d job(s) not healthy against the %s RPO (exit %d)\n",
			rep.Totals.Breaching, rep.Totals.Jobs, rep.Policy.RPO, exitUnhealthy)
	}
	fmt.Fprintf(w, "note: %s\n", reportNote)
}

func printSummary(w io.Writer, rep *healthReport) {
	fmt.Fprintf(w, "backupmedic %s  SUMMARY (read-only)\n", version)
	printPolicy(w, rep)
	fmt.Fprintln(w)
	printSkipped(w, rep.Skipped)

	if len(rep.Jobs) == 0 {
		fmt.Fprintf(w, "no runs recorded: there is nothing to judge yet.\n")
		fmt.Fprintf(w, "RESULT: 0 jobs known (exit %d)\n", rep.ExitCode)
		fmt.Fprintf(w, "note: %s\n", reportNote)
		return
	}

	fmt.Fprintf(w, "jobs healthy:   %d of %d\n", rep.Totals.Healthy, rep.Totals.Jobs)
	fmt.Fprintf(w, "jobs breaching: %d of %d\n", rep.Totals.Breaching, rep.Totals.Jobs)
	fmt.Fprintln(w)

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "VERDICT\tJOBS")
	for _, vc := range rep.Totals.ByVerdict {
		fmt.Fprintf(tw, "%s\t%d\n", vc.Verdict, vc.Jobs)
	}
	tw.Flush()
	fmt.Fprintln(w)

	if rep.Worst != nil {
		j := rep.Worst
		when := "never succeeded"
		if j.LastSuccess != "" {
			when = fmt.Sprintf("last success %s (%s ago)", j.LastSuccess, j.Age)
		}
		fmt.Fprintf(w, "worst offender: %s/%s  %s\n", j.Machine, j.Job, j.Verdict)
		fmt.Fprintf(w, "                %s, streak %d, success rate %.0f%% over last %d run(s)\n",
			when, j.FailStreak, j.SuccessRate, j.WindowRuns)
		for _, r := range j.Reasons {
			fmt.Fprintf(w, "                - %s\n", r)
		}
	} else {
		fmt.Fprintf(w, "worst offender: none, every job is HEALTHY\n")
	}
	fmt.Fprintln(w)

	fmt.Fprintf(w, "protected: %s across %d job(s), counting the most recent successful run of each (%d bytes)\n",
		rep.Totals.ProtectedBytesHum, rep.Totals.Jobs, rep.Totals.ProtectedBytes)
	if rep.ExitCode == exitOK {
		fmt.Fprintf(w, "RESULT: all %d job(s) HEALTHY within the %s RPO (exit %d)\n",
			rep.Totals.Jobs, rep.Policy.RPO, exitOK)
	} else {
		fmt.Fprintf(w, "RESULT: %d of %d job(s) not healthy against the %s RPO (exit %d)\n",
			rep.Totals.Breaching, rep.Totals.Jobs, rep.Policy.RPO, exitUnhealthy)
	}
	fmt.Fprintf(w, "note: %s\n", reportNote)
}

func printHistory(w io.Writer, rep *historyReport) {
	fmt.Fprintf(w, "backupmedic %s  HISTORY (read-only)\n", version)
	fmt.Fprintf(w, "history:   %s\n", rep.History)
	filter := "none"
	var parts []string
	if rep.Filter.Machine != "" {
		parts = append(parts, "machine="+rep.Filter.Machine)
	}
	if rep.Filter.Job != "" {
		parts = append(parts, "job="+rep.Filter.Job)
	}
	if rep.Filter.Last > 0 {
		parts = append(parts, fmt.Sprintf("last=%d", rep.Filter.Last))
	}
	if len(parts) > 0 {
		filter = strings.Join(parts, ", ")
	}
	fmt.Fprintf(w, "filter:    %s\n", filter)
	fmt.Fprintf(w, "runs:      %d shown of %d matching\n\n", rep.Shown, rep.Matched)
	printSkipped(w, rep.Skipped)

	if len(rep.Runs) == 0 {
		fmt.Fprintf(w, "no runs match.\n")
		return
	}
	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "LINE\tMACHINE\tJOB\tSTATUS\tSTARTED\tFINISHED\tDURATION\tBYTES\tNOTE")
	for _, r := range rep.Runs {
		fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
			r.Line, r.Machine, r.Job, strings.ToUpper(r.Status),
			r.Started, r.Finished, r.Duration, humanBytes(r.Bytes), r.Note)
	}
	tw.Flush()
	fmt.Fprintf(w, "\nnote: %s\n", reportNote)
}
