// SensorDeck is a sustained-load soak tester for desktop hardware health.
//
// It is the "does this machine slow down when you actually work it" sibling of
// the short-benchmark tools in this suite (CorePilot / ThermalFlow), of the
// one-shot report card (HardwareLens), of the disk-usage trend tool
// (DevicePulse) and of the fleet rollup (PCHealth). Where those measure a
// machine for a fraction of a second and print a number, SensorDeck keeps a
// fixed workload running for minutes and watches what happens to throughput.
//
// The mechanism is deliberately narrow and deliberately honest:
//
//   - Every interval performs an IDENTICAL, fixed number of operations over
//     the IDENTICAL set of input numbers. The operation count is constant for
//     the whole run; only the elapsed wall time varies. Throughput per
//     interval is therefore a clean measurement with no workload drift.
//
//   - A machine that thermally throttles gets measurably slower the longer it
//     is loaded. SensorDeck reports the first-window baseline, the final-window
//     average, the percentage decay between them, the interval where decay
//     first crossed the threshold, min/median/p95 throughput and an ASCII
//     sparkline of the curve so the shape is visible.
//
//   - SensorDeck does NOT read temperatures, fan RPM, SMART attributes or
//     power limits. Those need OS-privileged, vendor-specific native APIs
//     (WMI/IPMI, IOKit/SMC, per-vendor kernel drivers) that a portable,
//     dependency-free Go CLI cannot reach, and this program refuses to
//     pretend otherwise. Throughput decay is INDIRECT evidence: thermal
//     throttling is one possible cause among several (background processes,
//     power/battery profiles, noisy neighbours on shared hardware). It is a
//     signal worth investigating, not a diagnosis. See README.txt.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"math"
	"os"
	"os/signal"
	"runtime"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
)

const toolVersion = "1.0.0"

// Decay classification thresholds, in percent of the baseline throughput.
//
// Repeated idle soak runs on the same machine jitter by roughly a percent or
// two between the first and last window (scheduler noise, other processes,
// page cache effects), so mildDecayPct is set comfortably above that floor to
// avoid calling ordinary noise a problem. significantDecayPct is the point at
// which the machine is losing a big enough fraction of its sustained
// throughput that a user would actually feel it during long work.
const (
	mildDecayPct        = 5.0
	significantDecayPct = 15.0
)

// Verdict strings.
const (
	verdictStable      = "STABLE"
	verdictMild        = "MILD DECAY"
	verdictSignificant = "SIGNIFICANT DECAY"
)

// workloadBase is the first (odd) number checked for primality, and
// windowSlots is how many consecutive odd numbers the workload cycles over:
// slot k means the number workloadBase + 2*k, and slots wrap round at
// windowSlots.
//
// The wrap-around matters. Without it a worker marches off into ever-larger
// numbers, trial division has to go further before it can stop, and the cost
// of a single "operation" grows steadily through the run - which would show
// up as fake decay and would make the up-front calibration wildly
// inaccurate. Confining the workload to a narrow band (about 1.000e6 to
// 1.131e6) keeps sqrt(n), and therefore the cost of one check, essentially
// flat, so an operation means the same thing at the end of the soak as at
// the beginning.
const (
	workloadBase = uint64(1_000_003)
	windowSlots  = uint64(65536)
)

// sparkLevels are the sparkline cells, lowest bar first. One cell is emitted
// per recorded interval.
var sparkLevels = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}

// reorderFlags moves all flag tokens (and their values, for flags listed in
// valueFlags) before any positional arguments, working around the stdlib
// flag package's behavior of stopping parsing at the first positional arg.
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])
}

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

func usage() {
	fmt.Fprintf(os.Stderr, `SensorDeck `+toolVersion+` - sustained-load soak test: detect throttling by its effect

Usage:
  sensordeck soak    [--minutes 5] [--interval 10s] [--workers N] [--save run.json] [--json]
  sensordeck compare --before a.json --after b.json [--json]
  sensordeck show    --run run.json [--json]

Commands:
  soak     Run one fixed, identical unit of CPU work over and over for the
           whole duration, with NO idle time between units, and record the
           throughput of each unit. Reports the first-window baseline, the
           final-window average, the percentage decay between them, the
           interval where decay first crossed the threshold, min/median/p95
           throughput, an ASCII sparkline of the curve, and a verdict.
  compare  Diff two saved soak runs - the "did cleaning the fans / repasting
           the heatsink / moving the machine off the carpet actually change
           anything" command. Reports the change in baseline throughput and
           the change in decay.
  show     Re-print a saved run's full report without re-running anything.

Flags:
  --minutes N      soak only: how long to sustain the load, in minutes.
                   Fractional values are allowed (e.g. 1.5). (default 5)
  --interval dur   soak only: how long ONE unit of work should take, roughly.
                   SensorDeck calibrates an exact operation count to this
                   target once, up front, then holds that count fixed for
                   every interval of the run. Go duration syntax. (default 10s)
  --workers N      soak only: number of parallel worker goroutines.
                   (default: the machine's logical core count)
  --save FILE      soak only: write the full run, including every per-interval
                   measurement, to FILE as JSON. Writability is checked BEFORE
                   the soak starts so a bad path fails in a second, not in
                   five minutes.
  --before FILE    compare only: the saved run from before the change.
  --after FILE     compare only: the saved run from after the change.
  --run FILE       show only: the saved run to print.
  --json           Emit structured JSON instead of a text report.
  -h, --help       Show this help.

Verdicts: decay below %.0f%% of baseline is STABLE, %.0f%%-%.0f%% is MILD DECAY,
%.0f%% or more is SIGNIFICANT DECAY.

WHAT THIS IS NOT:
  SensorDeck does NOT read temperatures, fan speeds, SMART data or power
  limits. Those require OS-privileged, vendor-specific native APIs that a
  portable, dependency-free Go CLI cannot reach, and SensorDeck does not
  pretend to have them. Sustained-throughput decay is INDIRECT evidence.
  Thermal throttling is only ONE possible cause; background processes, power
  or battery profiles, and other tenants on shared hardware can all produce
  the same curve. Treat a decay verdict as a signal worth investigating, not
  as a diagnosis. See README.txt.
`, mildDecayPct, mildDecayPct, significantDecayPct, significantDecayPct)
}

func usageErr(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "sensordeck: "+format+"\n\n", a...)
	usage()
	os.Exit(1)
}

func fatal(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "sensordeck: "+format+"\n", a...)
	os.Exit(1)
}

// helpRequested reports whether any of args is a help token.
func helpRequested(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

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.Exit(1)
	}

	switch args[0] {
	case "-h", "--help", "help":
		usage()
		os.Exit(0)
	case "soak":
		cmdSoak(args[1:])
	case "compare":
		cmdCompare(args[1:])
	case "show":
		cmdShow(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "sensordeck: unknown command %q\n\n", args[0])
		usage()
		os.Exit(1)
	}
}

// ---------------------------------------------------------------------
// The workload
// ---------------------------------------------------------------------

// isPrime reports whether n is prime using simple trial division. This is the
// CPU-bound workload: cheap enough per check to run millions of times per
// second, expensive enough to exercise real integer arithmetic rather than
// being optimized away.
func isPrime(n uint64) bool {
	if n < 2 {
		return false
	}
	if n%2 == 0 {
		return n == 2
	}
	if n%3 == 0 {
		return n == 3
	}
	for i := uint64(5); i*i <= n; i += 6 {
		if n%i == 0 || n%(i+2) == 0 {
			return false
		}
	}
	return true
}

// sink keeps the compiler from eliding the workload.
var sink atomic.Uint64

// runBatch performs exactly count primality checks on the calling goroutine,
// walking slots startSlot, startSlot+stride, startSlot+2*stride, ... and
// wrapping round at windowSlots. It returns the number of checks actually
// performed, which equals count unless stop was raised part-way through
// (SIGINT/SIGTERM), in which case it may be less.
//
// Because startSlot/stride/count are fixed for the whole soak, every interval
// visits exactly the same sequence of numbers in exactly the same order - the
// work is not merely "the same amount", it is literally the same work.
func runBatch(startSlot, stride, count uint64, stop *atomic.Bool) uint64 {
	slot := startSlot % windowSlots
	var done uint64
	var acc uint64
	for done < count {
		if isPrime(workloadBase + 2*slot) {
			acc++
		}
		slot += stride
		if slot >= windowSlots {
			slot -= windowSlots
		}
		done++
		if done&2047 == 0 && stop.Load() {
			break
		}
	}
	sink.Add(acc)
	return done
}

// runInterval runs one interval: workers goroutines, each performing exactly
// perWorker checks over an interleaved slice of the same odd-number window.
// It returns the total checks performed and the wall-clock time it took.
func runInterval(workers int, perWorker uint64, stop *atomic.Bool) (uint64, time.Duration) {
	stride := uint64(workers) % windowSlots
	if stride == 0 {
		stride = 1
	}
	done := make([]uint64, workers)
	var wg sync.WaitGroup
	startTime := time.Now()
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			done[idx] = runBatch(uint64(idx), stride, perWorker, stop)
		}(i)
	}
	wg.Wait()
	elapsed := time.Since(startTime)
	var total uint64
	for _, d := range done {
		total += d
	}
	return total, elapsed
}

// calibrate estimates how many checks per worker take roughly target wall
// time, by running progressively larger probe batches until one lasts long
// enough to extrapolate from. It is run exactly once per soak; the resulting
// count is then frozen for every interval of that soak.
func calibrate(workers int, target time.Duration, stop *atomic.Bool) uint64 {
	// Start at a full pass over the number window so even the first probe
	// samples the same mix of cheap and expensive numbers the real intervals
	// will, rather than only the cheap low end.
	perWorker := windowSlots
	for i := 0; i < 12; i++ {
		_, elapsed := runInterval(workers, perWorker, stop)
		if stop.Load() {
			return perWorker
		}
		if elapsed >= 100*time.Millisecond {
			scale := target.Seconds() / elapsed.Seconds()
			if scale > 1 {
				est := float64(perWorker) * scale
				if est > 1e15 {
					est = 1e15
				}
				perWorker = uint64(est)
			}
			break
		}
		perWorker *= 4
	}
	if perWorker < 1000 {
		perWorker = 1000
	}
	return perWorker
}

// ---------------------------------------------------------------------
// Run model
// ---------------------------------------------------------------------

// intervalRecord is one measured unit of work. Operations is constant across
// every record of a run; only ElapsedSeconds (and therefore OpsPerSec) vary.
type intervalRecord struct {
	Index              int     `json:"index"`
	StartOffsetSeconds float64 `json:"start_offset_seconds"`
	Operations         uint64  `json:"operations"`
	ElapsedSeconds     float64 `json:"elapsed_seconds"`
	OpsPerSec          float64 `json:"ops_per_sec"`
}

// analysis is everything SensorDeck concludes from the interval series.
type analysis struct {
	IntervalCount           int     `json:"interval_count"`
	WindowSeconds           float64 `json:"window_seconds"`
	BaselineOpsPerSec       float64 `json:"baseline_ops_per_sec"`
	BaselineIntervals       int     `json:"baseline_intervals"`
	FinalOpsPerSec          float64 `json:"final_ops_per_sec"`
	FinalIntervals          int     `json:"final_intervals"`
	DecayPct                float64 `json:"decay_pct"`
	Verdict                 string  `json:"verdict"`
	DecayThresholdPct       float64 `json:"decay_threshold_pct"`
	FirstDecayIntervalIndex int     `json:"first_decay_interval_index"`
	FirstDecayOffsetSeconds float64 `json:"first_decay_offset_seconds"`
	FirstDecayOpsPerSec     float64 `json:"first_decay_ops_per_sec"`
	MinOpsPerSec            float64 `json:"min_ops_per_sec"`
	MedianOpsPerSec         float64 `json:"median_ops_per_sec"`
	P95OpsPerSec            float64 `json:"p95_ops_per_sec"`
	MaxOpsPerSec            float64 `json:"max_ops_per_sec"`
	Sparkline               string  `json:"sparkline"`
}

// soakRun is the complete, saveable record of one soak.
type soakRun struct {
	Tool                  string           `json:"tool"`
	Version               string           `json:"version"`
	StartedUTC            string           `json:"started_utc"`
	OS                    string           `json:"os"`
	Arch                  string           `json:"arch"`
	NumCPU                int              `json:"num_cpu"`
	Workers               int              `json:"workers"`
	RequestedMinutes      float64          `json:"requested_minutes"`
	TargetIntervalSeconds float64          `json:"target_interval_seconds"`
	OpsPerInterval        uint64           `json:"ops_per_interval"`
	TotalOperations       uint64           `json:"total_operations"`
	TotalElapsedSeconds   float64          `json:"total_elapsed_seconds"`
	Interrupted           bool             `json:"interrupted"`
	Note                  string           `json:"note,omitempty"`
	Intervals             []intervalRecord `json:"intervals"`
	Analysis              analysis         `json:"analysis"`
}

// ---------------------------------------------------------------------
// Statistics
//
// Percentile definitions used everywhere in this program, stated explicitly
// so they can be reproduced by hand:
//
//	median - the middle value of the ascending-sorted samples; for an even
//	         count, the arithmetic mean of the two middle values. (Same as
//	         Python's statistics.median.)
//	p95    - NEAREST-RANK: sort ascending, take the element at 1-based index
//	         ceil(0.95 * n), clamped to [1, n]. No interpolation. (Same as
//	         Python's sorted(xs)[math.ceil(0.95*len(xs))-1].)
// ---------------------------------------------------------------------

// opsSlice pulls the throughput figures out of a run of intervals.
func opsSlice(ivs []intervalRecord) []float64 {
	out := make([]float64, 0, len(ivs))
	for _, iv := range ivs {
		out = append(out, iv.OpsPerSec)
	}
	return out
}

func medianOf(sorted []float64) float64 {
	n := len(sorted)
	if n == 0 {
		return 0
	}
	if n%2 == 1 {
		return sorted[n/2]
	}
	return (sorted[n/2-1] + sorted[n/2]) / 2
}

func nearestRankPercentile(sorted []float64, p float64) float64 {
	n := len(sorted)
	if n == 0 {
		return 0
	}
	rank := int(math.Ceil(p / 100 * float64(n)))
	if rank < 1 {
		rank = 1
	}
	if rank > n {
		rank = n
	}
	return sorted[rank-1]
}

// windowStat reduces a window of per-interval throughputs to the single
// number the baseline/final comparison uses.
//
// It is the MEDIAN, not the mean, and that choice is deliberate. Real
// machines - laptops with an antivirus scan waking up, shared or virtualised
// hosts with noisy neighbours - throw the occasional interval that is half
// speed for reasons that have nothing to do with the machine's sustained
// capability. A mean lets one such interval move the baseline several
// percent and manufacture decay that is not there; a median shrugs it off
// while still tracking a genuine, sustained slowdown, which by definition
// affects most of the intervals in the window.
func windowStat(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	s := append([]float64(nil), xs...)
	sort.Float64s(s)
	return medianOf(s)
}

// sparkline renders one cell per sample. The lowest sample gets the lowest
// bar, the highest sample the highest bar, and everything in between is
// linearly scaled and rounded to the nearest of the 8 levels. A flat series
// (max == min) renders as all-lowest bars.
func sparkline(vals []float64) string {
	if len(vals) == 0 {
		return ""
	}
	lo, hi := vals[0], vals[0]
	for _, v := range vals {
		if v < lo {
			lo = v
		}
		if v > hi {
			hi = v
		}
	}
	var b strings.Builder
	span := hi - lo
	for _, v := range vals {
		level := 0
		if span > 0 {
			level = int(math.Round((v - lo) / span * float64(len(sparkLevels)-1)))
		}
		if level < 0 {
			level = 0
		}
		if level >= len(sparkLevels) {
			level = len(sparkLevels) - 1
		}
		b.WriteRune(sparkLevels[level])
	}
	return b.String()
}

// classify turns a decay percentage (positive = slower at the end) into a
// verdict.
func classify(decayPct float64) string {
	switch {
	case decayPct >= significantDecayPct:
		return verdictSignificant
	case decayPct >= mildDecayPct:
		return verdictMild
	default:
		return verdictStable
	}
}

// analyze computes the baseline/final/decay figures and distribution stats.
//
// The baseline window is the first windowSeconds of the run and the final
// window is the last windowSeconds. windowSeconds is 60s for runs of three
// minutes or more (the "first minute vs final minute" the report describes);
// for shorter runs it shrinks to one third of the run so the two windows
// stay disjoint and each still contains at least one interval.
func analyze(intervals []intervalRecord, totalElapsed float64) analysis {
	a := analysis{
		IntervalCount:           len(intervals),
		DecayThresholdPct:       mildDecayPct,
		FirstDecayIntervalIndex: -1,
	}
	if len(intervals) == 0 {
		return a
	}

	window := 60.0
	if totalElapsed < 180 {
		window = totalElapsed / 3
	}
	if window <= 0 {
		window = totalElapsed
	}
	a.WindowSeconds = window

	var baseVals, finalVals, allVals []float64
	finalCut := totalElapsed - window
	for _, iv := range intervals {
		allVals = append(allVals, iv.OpsPerSec)
		if iv.StartOffsetSeconds < window {
			baseVals = append(baseVals, iv.OpsPerSec)
		}
		if iv.StartOffsetSeconds >= finalCut {
			finalVals = append(finalVals, iv.OpsPerSec)
		}
	}
	// Guarantee both windows are non-empty even for degenerate runs, and -
	// when the run is long enough to afford it - that each window holds at
	// least minWindowIntervals samples. A median over one or two samples is
	// not a median; widening to three is what makes a single contended
	// interval outvotable.
	const minWindowIntervals = 3
	if len(intervals) >= 2*minWindowIntervals {
		if len(baseVals) < minWindowIntervals {
			baseVals = opsSlice(intervals[:minWindowIntervals])
		}
		if len(finalVals) < minWindowIntervals {
			finalVals = opsSlice(intervals[len(intervals)-minWindowIntervals:])
		}
	}
	if len(baseVals) == 0 {
		baseVals = []float64{intervals[0].OpsPerSec}
	}
	if len(finalVals) == 0 {
		finalVals = []float64{intervals[len(intervals)-1].OpsPerSec}
	}

	a.BaselineIntervals = len(baseVals)
	a.FinalIntervals = len(finalVals)
	a.BaselineOpsPerSec = windowStat(baseVals)
	a.FinalOpsPerSec = windowStat(finalVals)
	if a.BaselineOpsPerSec > 0 {
		a.DecayPct = (a.BaselineOpsPerSec - a.FinalOpsPerSec) / a.BaselineOpsPerSec * 100
	}
	a.Verdict = classify(a.DecayPct)

	// First interval whose throughput fell more than the threshold below the
	// baseline. Intervals inside the baseline window itself are skipped -
	// they are what the baseline is made of.
	if a.BaselineOpsPerSec > 0 {
		limit := a.BaselineOpsPerSec * (1 - mildDecayPct/100)
		for _, iv := range intervals {
			if iv.StartOffsetSeconds < window {
				continue
			}
			if iv.OpsPerSec < limit {
				a.FirstDecayIntervalIndex = iv.Index
				a.FirstDecayOffsetSeconds = iv.StartOffsetSeconds
				a.FirstDecayOpsPerSec = iv.OpsPerSec
				break
			}
		}
	}

	sorted := append([]float64(nil), allVals...)
	sort.Float64s(sorted)
	a.MinOpsPerSec = sorted[0]
	a.MaxOpsPerSec = sorted[len(sorted)-1]
	a.MedianOpsPerSec = medianOf(sorted)
	a.P95OpsPerSec = nearestRankPercentile(sorted, 95)
	a.Sparkline = sparkline(allVals)
	return a
}

// ---------------------------------------------------------------------
// "soak" command
// ---------------------------------------------------------------------

func cmdSoak(args []string) {
	if helpRequested(args) {
		usage()
		os.Exit(0)
	}

	valueFlags := map[string]bool{"minutes": true, "interval": true, "workers": true, "save": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("soak", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	minutes := fs.Float64("minutes", 5, "how long to sustain the load, in minutes")
	interval := fs.Duration("interval", 10*time.Second, "target wall time for one unit of work")
	workers := fs.Int("workers", runtime.NumCPU(), "number of parallel worker goroutines")
	save := fs.String("save", "", "write the full run to this file as JSON")
	jsonOut := fs.Bool("json", false, "emit structured JSON output")
	if err := fs.Parse(args); err != nil {
		usageErr("%v", err)
	}
	if fs.NArg() > 0 {
		usageErr("unexpected argument %q", fs.Arg(0))
	}

	if *minutes <= 0 {
		usageErr("--minutes must be greater than 0 (got %g)", *minutes)
	}
	if math.IsNaN(*minutes) || math.IsInf(*minutes, 0) {
		usageErr("--minutes must be a finite number")
	}
	if *interval <= 0 {
		usageErr("--interval must be a positive duration (got %s)", interval.String())
	}
	if *workers <= 0 {
		usageErr("--workers must be at least 1 (got %d)", *workers)
	}

	total := time.Duration(*minutes * float64(time.Minute))
	if *interval > total {
		usageErr("--interval (%s) is longer than the total soak duration (%s); "+
			"a soak needs at least one full interval, so lower --interval or raise --minutes",
			interval.String(), total.Round(time.Second).String())
	}

	// Fail fast on an unwritable --save path: better a one-second error than
	// a five-minute soak that cannot be saved.
	if *save != "" {
		f, err := os.OpenFile(*save, os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			fatal("cannot write --save file %s: %v", *save, err)
		}
		f.Close()
	}

	var stop atomic.Bool
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	defer signal.Stop(sigCh)
	go func() {
		<-sigCh
		stop.Store(true)
	}()

	run := doSoak(*minutes, total, *interval, *workers, *jsonOut, &stop)

	if len(run.Intervals) == 0 {
		fatal("no interval completed - nothing to report")
	}

	if *save != "" {
		if err := saveRun(*save, run); err != nil {
			fatal("saving run: %v", err)
		}
	}

	if *jsonOut {
		emitJSON(run)
	} else {
		printSoakReport(run)
		if *save != "" {
			if st, err := os.Stat(*save); err == nil {
				fmt.Printf("\nSaved run to %s (%s)\n", *save, humanBytes(st.Size()))
			} else {
				fmt.Printf("\nSaved run to %s\n", *save)
			}
		}
	}
}

// doSoak runs the whole soak and returns the completed run record.
func doSoak(minutes float64, total, interval time.Duration, workers int, quiet bool, stop *atomic.Bool) soakRun {
	if !quiet {
		fmt.Printf("SensorDeck %s - sustained-load soak\n", toolVersion)
		fmt.Printf("Host: %s/%s, %d logical cores. Workers: %d. Duration: %s. Target interval: %s.\n",
			runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), workers,
			total.Round(time.Second), interval.Round(time.Millisecond))
		fmt.Printf("Calibrating a fixed operation count for a ~%s interval...\n", interval.Round(time.Millisecond))
	}

	perWorker := calibrate(workers, interval, stop)
	opsPerInterval := perWorker * uint64(workers)

	if !quiet {
		fmt.Printf("Locked in %d operations per interval (%d per worker x %d workers).\n",
			opsPerInterval, perWorker, workers)
		fmt.Println("This count NEVER changes during the run - only the elapsed time does.")
		fmt.Println()
		fmt.Printf("%5s %9s %14s %16s\n", "IVL", "T+", "ELAPSED", "OPS/SEC")
	}

	run := soakRun{
		Tool:                  "sensordeck",
		Version:               toolVersion,
		StartedUTC:            time.Now().UTC().Format(time.RFC3339),
		OS:                    runtime.GOOS,
		Arch:                  runtime.GOARCH,
		NumCPU:                runtime.NumCPU(),
		Workers:               workers,
		RequestedMinutes:      minutes,
		TargetIntervalSeconds: interval.Seconds(),
		OpsPerInterval:        opsPerInterval,
		Intervals:             []intervalRecord{},
	}

	soakStart := time.Now()
	idx := 0
	for {
		if stop.Load() {
			break
		}
		offset := time.Since(soakStart)
		if offset >= total {
			break
		}
		idx++
		ops, elapsed := runInterval(workers, perWorker, stop)
		if ops != opsPerInterval {
			// Only possible when a signal cut the interval short. A partial
			// interval is not comparable to a full one, so it is discarded.
			break
		}
		rec := intervalRecord{
			Index:              idx,
			StartOffsetSeconds: offset.Seconds(),
			Operations:         ops,
			ElapsedSeconds:     elapsed.Seconds(),
			OpsPerSec:          float64(ops) / elapsed.Seconds(),
		}
		run.Intervals = append(run.Intervals, rec)
		run.TotalOperations += ops
		if !quiet {
			fmt.Printf("%5d %8.1fs %13.3fs %16.0f\n", rec.Index, rec.StartOffsetSeconds, rec.ElapsedSeconds, rec.OpsPerSec)
		}
	}
	run.TotalElapsedSeconds = time.Since(soakStart).Seconds()
	if stop.Load() {
		run.Interrupted = true
		run.Note = "Interrupted by signal. The partial interval in flight was discarded; " +
			"every interval below is a complete, full-size unit of work, so the run is still usable."
	}
	run.Analysis = analyze(run.Intervals, run.TotalElapsedSeconds)
	return run
}

func saveRun(path string, run soakRun) error {
	b, err := json.MarshalIndent(run, "", "  ")
	if err != nil {
		return err
	}
	b = append(b, '\n')
	return os.WriteFile(path, b, 0644)
}

func loadRun(path string) (soakRun, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return soakRun{}, err
	}
	var run soakRun
	dec := json.NewDecoder(strings.NewReader(string(b)))
	if err := dec.Decode(&run); err != nil {
		return soakRun{}, fmt.Errorf("%s: not a valid SensorDeck run file: %v", path, err)
	}
	if run.Tool != "sensordeck" {
		return soakRun{}, fmt.Errorf("%s: not a SensorDeck run file (missing \"tool\": \"sensordeck\")", path)
	}
	if len(run.Intervals) == 0 {
		return soakRun{}, fmt.Errorf("%s: run file contains no intervals", path)
	}
	return run, nil
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fatal("failed to encode JSON: %v", err)
	}
}

func printSoakReport(run soakRun) {
	a := run.Analysis
	fmt.Println()
	fmt.Println("=== SensorDeck soak report ===")
	fmt.Printf("Started (UTC):        %s\n", run.StartedUTC)
	fmt.Printf("Host:                 %s/%s, %d logical cores\n", run.OS, run.Arch, run.NumCPU)
	fmt.Printf("Workers:              %d\n", run.Workers)
	fmt.Printf("Requested duration:   %.4g min\n", run.RequestedMinutes)
	fmt.Printf("Actual duration:      %.1fs\n", run.TotalElapsedSeconds)
	fmt.Printf("Target interval:      %.3gs\n", run.TargetIntervalSeconds)
	fmt.Printf("Ops per interval:     %d  (IDENTICAL every interval - only elapsed time varies)\n", run.OpsPerInterval)
	fmt.Printf("Intervals recorded:   %d\n", a.IntervalCount)
	fmt.Printf("Total operations:     %d\n", run.TotalOperations)
	if run.Interrupted {
		fmt.Printf("Interrupted:          yes - %s\n", run.Note)
	}
	fmt.Println()

	fmt.Println("Throughput curve (one cell per interval, lowest sample = lowest bar):")
	fmt.Printf("  %s\n", a.Sparkline)
	fmt.Printf("  first interval %.0f ops/s ... last interval %.0f ops/s\n",
		run.Intervals[0].OpsPerSec, run.Intervals[len(run.Intervals)-1].OpsPerSec)
	fmt.Println()

	fmt.Printf("Baseline (first %.1fs):  %12.0f ops/sec   median of %d interval(s)\n",
		a.WindowSeconds, a.BaselineOpsPerSec, a.BaselineIntervals)
	fmt.Printf("Final    (last  %.1fs):  %12.0f ops/sec   median of %d interval(s)\n",
		a.WindowSeconds, a.FinalOpsPerSec, a.FinalIntervals)
	fmt.Printf("Decay baseline->final:  %11.2f%%   (positive = slower at the end)\n", a.DecayPct)
	if a.FirstDecayIntervalIndex > 0 {
		fmt.Printf("First interval more than %.0f%% below baseline: #%d at t=%.1fs (%.0f ops/sec)\n",
			a.DecayThresholdPct, a.FirstDecayIntervalIndex, a.FirstDecayOffsetSeconds, a.FirstDecayOpsPerSec)
	} else {
		fmt.Printf("First interval more than %.0f%% below baseline: none - throughput never dropped that far\n",
			a.DecayThresholdPct)
	}
	fmt.Println()
	fmt.Printf("Distribution (ops/sec): min %.0f   median %.0f   p95 %.0f   max %.0f\n",
		a.MinOpsPerSec, a.MedianOpsPerSec, a.P95OpsPerSec, a.MaxOpsPerSec)
	fmt.Println("  (median = middle sample, mean of the two middle samples when the count is")
	fmt.Println("   even; p95 = nearest-rank, ascending sort, 1-based index ceil(0.95*n).")
	fmt.Println("   The baseline and final figures above are the MEDIAN of their window, so a")
	fmt.Println("   single freak interval cannot manufacture or hide decay.)")
	fmt.Println()
	fmt.Printf("VERDICT: %s\n", a.Verdict)
	fmt.Printf("  (below %.0f%% decay = STABLE, %.0f-%.0f%% = MILD DECAY, %.0f%%+ = SIGNIFICANT DECAY)\n",
		mildDecayPct, mildDecayPct, significantDecayPct, significantDecayPct)
	fmt.Println()
	fmt.Println("What this means: throughput decay under sustained load is INDIRECT evidence.")
	fmt.Println("Thermal throttling is only ONE possible cause. Others include background")
	fmt.Println("processes starting mid-run, power or battery profiles stepping the CPU down,")
	fmt.Println("OS scheduler changes, and other tenants on shared or virtual hardware.")
	fmt.Println("SensorDeck does NOT read temperatures, fan speeds or SMART data - those need")
	fmt.Println("privileged, vendor-specific native APIs. Treat a decay verdict as a signal")
	fmt.Println("worth investigating, not as a diagnosis.")
}

// ---------------------------------------------------------------------
// "compare" command
// ---------------------------------------------------------------------

type compareOutput struct {
	Tool                string  `json:"tool"`
	Version             string  `json:"version"`
	BeforeFile          string  `json:"before_file"`
	AfterFile           string  `json:"after_file"`
	BeforeStartedUTC    string  `json:"before_started_utc"`
	AfterStartedUTC     string  `json:"after_started_utc"`
	BeforeBaseline      float64 `json:"before_baseline_ops_per_sec"`
	AfterBaseline       float64 `json:"after_baseline_ops_per_sec"`
	BaselineChangePct   float64 `json:"baseline_change_pct"`
	BeforeFinal         float64 `json:"before_final_ops_per_sec"`
	AfterFinal          float64 `json:"after_final_ops_per_sec"`
	FinalChangePct      float64 `json:"final_change_pct"`
	BeforeDecayPct      float64 `json:"before_decay_pct"`
	AfterDecayPct       float64 `json:"after_decay_pct"`
	DecayChangePoints   float64 `json:"decay_change_points"`
	BeforeVerdict       string  `json:"before_verdict"`
	AfterVerdict        string  `json:"after_verdict"`
	VerdictChanged      bool    `json:"verdict_changed"`
	BeforeMedian        float64 `json:"before_median_ops_per_sec"`
	AfterMedian         float64 `json:"after_median_ops_per_sec"`
	MedianChangePct     float64 `json:"median_change_pct"`
	SameOpsPerInterval  bool    `json:"same_ops_per_interval"`
	BeforeOpsPerIntrvl  uint64  `json:"before_ops_per_interval"`
	AfterOpsPerInterval uint64  `json:"after_ops_per_interval"`
	Summary             string  `json:"summary"`
}

func pctChange(oldVal, newVal float64) float64 {
	if oldVal == 0 {
		return 0
	}
	return (newVal - oldVal) / oldVal * 100
}

func cmdCompare(args []string) {
	if helpRequested(args) {
		usage()
		os.Exit(0)
	}

	valueFlags := map[string]bool{"before": true, "after": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("compare", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	before := fs.String("before", "", "saved run from before the change")
	after := fs.String("after", "", "saved run from after the change")
	jsonOut := fs.Bool("json", false, "emit structured JSON output")
	if err := fs.Parse(args); err != nil {
		usageErr("%v", err)
	}
	if fs.NArg() > 0 {
		usageErr("unexpected argument %q", fs.Arg(0))
	}
	if *before == "" || *after == "" {
		usageErr("compare requires both --before FILE and --after FILE")
	}

	b, err := loadRun(*before)
	if err != nil {
		fatal("%v", err)
	}
	a, err := loadRun(*after)
	if err != nil {
		fatal("%v", err)
	}

	out := compareOutput{
		Tool:                "sensordeck",
		Version:             toolVersion,
		BeforeFile:          *before,
		AfterFile:           *after,
		BeforeStartedUTC:    b.StartedUTC,
		AfterStartedUTC:     a.StartedUTC,
		BeforeBaseline:      b.Analysis.BaselineOpsPerSec,
		AfterBaseline:       a.Analysis.BaselineOpsPerSec,
		BaselineChangePct:   pctChange(b.Analysis.BaselineOpsPerSec, a.Analysis.BaselineOpsPerSec),
		BeforeFinal:         b.Analysis.FinalOpsPerSec,
		AfterFinal:          a.Analysis.FinalOpsPerSec,
		FinalChangePct:      pctChange(b.Analysis.FinalOpsPerSec, a.Analysis.FinalOpsPerSec),
		BeforeDecayPct:      b.Analysis.DecayPct,
		AfterDecayPct:       a.Analysis.DecayPct,
		DecayChangePoints:   a.Analysis.DecayPct - b.Analysis.DecayPct,
		BeforeVerdict:       b.Analysis.Verdict,
		AfterVerdict:        a.Analysis.Verdict,
		VerdictChanged:      b.Analysis.Verdict != a.Analysis.Verdict,
		BeforeMedian:        b.Analysis.MedianOpsPerSec,
		AfterMedian:         a.Analysis.MedianOpsPerSec,
		MedianChangePct:     pctChange(b.Analysis.MedianOpsPerSec, a.Analysis.MedianOpsPerSec),
		SameOpsPerInterval:  b.OpsPerInterval == a.OpsPerInterval,
		BeforeOpsPerIntrvl:  b.OpsPerInterval,
		AfterOpsPerInterval: a.OpsPerInterval,
	}
	out.Summary = compareSummary(out)

	if *jsonOut {
		emitJSON(out)
		return
	}

	fmt.Println("=== SensorDeck compare ===")
	fmt.Printf("Before: %s  (started %s)\n", out.BeforeFile, out.BeforeStartedUTC)
	fmt.Printf("After:  %s  (started %s)\n", out.AfterFile, out.AfterStartedUTC)
	fmt.Println()
	fmt.Printf("%-24s %14s %14s %12s\n", "METRIC", "BEFORE", "AFTER", "CHANGE")
	fmt.Printf("%-24s %14.0f %14.0f %11.2f%%\n", "Baseline ops/sec", out.BeforeBaseline, out.AfterBaseline, out.BaselineChangePct)
	fmt.Printf("%-24s %14.0f %14.0f %11.2f%%\n", "Final ops/sec", out.BeforeFinal, out.AfterFinal, out.FinalChangePct)
	fmt.Printf("%-24s %14.0f %14.0f %11.2f%%\n", "Median ops/sec", out.BeforeMedian, out.AfterMedian, out.MedianChangePct)
	fmt.Printf("%-24s %13.2f%% %13.2f%% %10.2fpp\n", "Decay baseline->final", out.BeforeDecayPct, out.AfterDecayPct, out.DecayChangePoints)
	fmt.Printf("%-24s %14s %14s %12s\n", "Verdict", out.BeforeVerdict, out.AfterVerdict, changedWord(out.VerdictChanged))
	fmt.Println()
	fmt.Printf("Curve before: %s\n", b.Analysis.Sparkline)
	fmt.Printf("Curve after:  %s\n", a.Analysis.Sparkline)
	fmt.Println()
	if !out.SameOpsPerInterval {
		fmt.Printf("NOTE: the two runs used different per-interval operation counts (%d vs %d),\n",
			out.BeforeOpsPerIntrvl, out.AfterOpsPerInterval)
		fmt.Println("      because each soak calibrates its own. Absolute ops/sec are still")
		fmt.Println("      comparable (same workload, same arithmetic); the DECAY figures are the")
		fmt.Println("      most robust thing to compare across runs.")
		fmt.Println()
	}
	fmt.Printf("%s\n", out.Summary)
	fmt.Println()
	fmt.Println("Reminder: SensorDeck measures throughput, not temperature. A change here")
	fmt.Println("says the machine's sustained speed changed - it does not, on its own, prove")
	fmt.Println("why. Keep the machine's workload and power settings the same between runs.")
}

func changedWord(b bool) string {
	if b {
		return "CHANGED"
	}
	return "same"
}

func compareSummary(o compareOutput) string {
	var parts []string
	switch {
	case math.Abs(o.BaselineChangePct) < 2:
		parts = append(parts, fmt.Sprintf("Baseline speed is essentially unchanged (%+.2f%%).", o.BaselineChangePct))
	case o.BaselineChangePct > 0:
		parts = append(parts, fmt.Sprintf("Baseline speed improved by %.2f%%.", o.BaselineChangePct))
	default:
		parts = append(parts, fmt.Sprintf("Baseline speed dropped by %.2f%%.", -o.BaselineChangePct))
	}
	switch {
	case math.Abs(o.DecayChangePoints) < 2:
		parts = append(parts, fmt.Sprintf("Decay under sustained load is essentially unchanged (%+.2f percentage points).", o.DecayChangePoints))
	case o.DecayChangePoints < 0:
		parts = append(parts, fmt.Sprintf("Decay under sustained load improved by %.2f percentage points.", -o.DecayChangePoints))
	default:
		parts = append(parts, fmt.Sprintf("Decay under sustained load got worse by %.2f percentage points.", o.DecayChangePoints))
	}
	if o.VerdictChanged {
		parts = append(parts, fmt.Sprintf("The verdict changed from %s to %s.", o.BeforeVerdict, o.AfterVerdict))
	} else {
		parts = append(parts, fmt.Sprintf("The verdict is %s in both runs.", o.AfterVerdict))
	}
	return strings.Join(parts, " ")
}

// ---------------------------------------------------------------------
// "show" command
// ---------------------------------------------------------------------

func cmdShow(args []string) {
	if helpRequested(args) {
		usage()
		os.Exit(0)
	}

	valueFlags := map[string]bool{"run": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("show", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	runPath := fs.String("run", "", "saved run file to print")
	jsonOut := fs.Bool("json", false, "emit structured JSON output")
	if err := fs.Parse(args); err != nil {
		usageErr("%v", err)
	}
	if fs.NArg() > 0 {
		usageErr("unexpected argument %q", fs.Arg(0))
	}
	if *runPath == "" {
		usageErr("show requires --run FILE")
	}

	run, err := loadRun(*runPath)
	if err != nil {
		fatal("%v", err)
	}

	if *jsonOut {
		emitJSON(run)
		return
	}

	fmt.Printf("Saved run: %s\n", *runPath)
	fmt.Println()
	fmt.Printf("%5s %9s %16s %14s %16s\n", "IVL", "T+", "OPERATIONS", "ELAPSED", "OPS/SEC")
	for _, iv := range run.Intervals {
		fmt.Printf("%5d %8.1fs %16d %13.3fs %16.0f\n", iv.Index, iv.StartOffsetSeconds, iv.Operations, iv.ElapsedSeconds, iv.OpsPerSec)
	}
	printSoakReport(run)
}
