// Command sectorpilot is a read-only block-level surface scanner.
//
// It walks a disk image (or any file) block by block, times every single read,
// and classifies each block as OK, SLOW or FAILED. The result is a health map
// of the whole surface that localises trouble to a specific byte offset, plus a
// saved scan that can be compared against a later one to see whether damage is
// spreading.
//
// sectorpilot never writes to the image it scans. The image is opened with
// os.Open (read-only) and every access is a bounds-checked ReadAt.
package main

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	toolName   = "sectorpilot"
	scanFormat = 1

	exitUsage    = 1
	exitFindings = 2

	statusOK     = "OK"
	statusSlow   = "SLOW"
	statusFailed = "FAILED"

	maxMapCells = 256
	mapRowWidth = 64

	adaptiveMultiple = 8.0
	adaptiveFloorMS  = 1.0

	minBlockSize = 512
	maxBlockSize = 64 << 20
	maxBlocks    = 8000000

	fingerprintEdge = 4096
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX 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])
}

// ---------------------------------------------------------------------------
// Size parsing.
// ---------------------------------------------------------------------------

// parseSize accepts a plain byte count or a binary-suffixed one: 4096, 64KB,
// 64KiB, 1M, 1MiB, 1G. Suffixes are powers of 1024 (KB == KiB == 1024 bytes).
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, fmt.Errorf("empty size")
	}
	up := strings.ToUpper(t)
	i := 0
	for i < len(up) && up[i] >= '0' && up[i] <= '9' {
		i++
	}
	digits := up[:i]
	suffix := strings.TrimSpace(up[i:])
	if digits == "" {
		return 0, fmt.Errorf("%q does not start with a number", s)
	}
	n, err := strconv.ParseInt(digits, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("%q is not a valid byte count", s)
	}
	var mult int64
	switch suffix {
	case "", "B":
		mult = 1
	case "K", "KB", "KIB":
		mult = 1 << 10
	case "M", "MB", "MIB":
		mult = 1 << 20
	case "G", "GB", "GIB":
		mult = 1 << 30
	default:
		return 0, fmt.Errorf("unknown size suffix %q in %q (use B, KB, MB or GB; all powers of 1024)", suffix, s)
	}
	if n != 0 && n > (int64(1)<<62)/mult {
		return 0, fmt.Errorf("%q is too large", s)
	}
	return n * mult, nil
}

// ---------------------------------------------------------------------------
// Scan records. These are exactly what --save writes and what compare/verify
// read back.
// ---------------------------------------------------------------------------

type blockResult struct {
	Index  int64   `json:"index"`
	Offset int64   `json:"offset"`
	Length int64   `json:"length"`
	MS     float64 `json:"ms"`
	Status string  `json:"status"`
	Error  string  `json:"error,omitempty"`
}

type classCounts struct {
	OK     int64 `json:"ok"`
	Slow   int64 `json:"slow"`
	Failed int64 `json:"failed"`
}

type timingStats struct {
	Min      float64 `json:"min"`
	Median   float64 `json:"median"`
	P95      float64 `json:"p95"`
	Max      float64 `json:"max"`
	Mean     float64 `json:"mean"`
	Elapsed  float64 `json:"total_elapsed"`
	Measured int64   `json:"measured_blocks"`
}

type surfaceMap struct {
	Cells         int      `json:"cells"`
	RowWidth      int      `json:"row_width"`
	BlocksPerCell float64  `json:"blocks_per_cell"`
	Rows          []string `json:"rows"`
	RowOffsets    []int64  `json:"row_offsets"`
	Legend        string   `json:"legend"`
}

type scanRecord struct {
	Tool          string        `json:"tool"`
	Format        int           `json:"format"`
	ScannedAt     string        `json:"scanned_at"`
	Image         string        `json:"image"`
	ImageSize     int64         `json:"image_size"`
	Fingerprint   string        `json:"fingerprint"`
	BlockSize     int64         `json:"block_size"`
	TotalBlocks   int64         `json:"total_blocks"`
	BytesRead     int64         `json:"bytes_read"`
	ThresholdMode string        `json:"threshold_mode"`
	SlowMS        float64       `json:"slow_ms"`
	ThresholdDesc string        `json:"threshold_description"`
	Counts        classCounts   `json:"counts"`
	Timing        timingStats   `json:"timing_ms"`
	Flagged       []blockResult `json:"flagged"`
	Slowest       []blockResult `json:"slowest"`
	Map           *surfaceMap   `json:"map,omitempty"`
}

// blocksOf returns "block" or "blocks" for readable counts.
func blocksOf(n int64) string {
	if n == 1 {
		return "block"
	}
	return "blocks"
}

// ---------------------------------------------------------------------------
// Target: the file under inspection. Opened read-only, nothing else.
// ---------------------------------------------------------------------------

type target struct {
	path string
	f    *os.File
	size int64
}

func openTarget(path string) (*target, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	st, err := f.Stat()
	if err != nil {
		f.Close()
		return nil, err
	}
	if st.IsDir() {
		f.Close()
		return nil, fmt.Errorf("%s is a directory, not an image or file", path)
	}
	if !st.Mode().IsRegular() {
		f.Close()
		return nil, fmt.Errorf("%s is not a regular file; sectorpilot scans files and images, not raw block devices or special files", path)
	}
	if st.Size() == 0 {
		f.Close()
		return nil, fmt.Errorf("%s is empty (0 bytes): there is no surface to scan", path)
	}
	abs := path
	if a, aerr := filepath.Abs(path); aerr == nil {
		abs = a
	}
	return &target{path: abs, f: f, size: st.Size()}, nil
}

func (t *target) Close() error { return t.f.Close() }

// fingerprint identifies the image cheaply: size plus the first and last 4 KiB.
// It is used to refuse a scan file that plainly belongs to a different image.
func (t *target) fingerprint() (string, error) {
	n := int64(fingerprintEdge)
	if t.size < n {
		n = t.size
	}
	head := make([]byte, n)
	if _, err := t.f.ReadAt(head, 0); err != nil && err != io.EOF {
		return "", err
	}
	tail := make([]byte, n)
	if _, err := t.f.ReadAt(tail, t.size-n); err != nil && err != io.EOF {
		return "", err
	}
	var sz [8]byte
	binary.BigEndian.PutUint64(sz[:], uint64(t.size))
	buf := make([]byte, 0, 8+len(head)+len(tail))
	buf = append(buf, sz[:]...)
	buf = append(buf, head...)
	buf = append(buf, tail...)
	sum := sha256.Sum256(buf)
	return "sha256:" + hex.EncodeToString(sum[:]), nil
}

// timedRead performs one measured read. It returns the wall-clock duration of
// the read syscall path and an error string when the block could not be read
// in full.
func (t *target) timedRead(buf []byte, off int64) (float64, string) {
	start := time.Now()
	n, err := t.f.ReadAt(buf, off)
	ms := float64(time.Since(start).Nanoseconds()) / 1e6
	if n == len(buf) {
		return ms, ""
	}
	if err != nil && err != io.EOF {
		return ms, err.Error()
	}
	return ms, fmt.Sprintf("short read: wanted %d bytes at offset %d, got %d (data ends early: the file was truncated, or the media returned nothing)", len(buf), off, n)
}

// ---------------------------------------------------------------------------
// Statistics.
// ---------------------------------------------------------------------------

func percentile(sorted []float64, p float64) float64 {
	if len(sorted) == 0 {
		return 0
	}
	if len(sorted) == 1 {
		return sorted[0]
	}
	idx := int(p*float64(len(sorted))+0.999999) - 1
	if idx < 0 {
		idx = 0
	}
	if idx >= len(sorted) {
		idx = len(sorted) - 1
	}
	return sorted[idx]
}

func median(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 statsOf(samples []float64, elapsed float64) timingStats {
	s := timingStats{Elapsed: elapsed, Measured: int64(len(samples))}
	if len(samples) == 0 {
		return s
	}
	sorted := make([]float64, len(samples))
	copy(sorted, samples)
	sort.Float64s(sorted)
	var sum float64
	for _, v := range sorted {
		sum += v
	}
	s.Min = sorted[0]
	s.Max = sorted[len(sorted)-1]
	s.Median = median(sorted)
	s.P95 = percentile(sorted, 0.95)
	s.Mean = sum / float64(len(sorted))
	return s
}

// ---------------------------------------------------------------------------
// Surface map.
// ---------------------------------------------------------------------------

const mapLegend = "'.' every block in the cell read OK   '!' cell contains SLOW blocks   'X' cell contains FAILED blocks"

func buildMap(statuses []string, blockSize int64) *surfaceMap {
	total := int64(len(statuses))
	if total == 0 {
		return nil
	}
	cells := maxMapCells
	if total < int64(cells) {
		cells = int(total)
	}
	m := &surfaceMap{
		Cells:         cells,
		RowWidth:      mapRowWidth,
		BlocksPerCell: float64(total) / float64(cells),
		Legend:        mapLegend,
		Rows:          []string{},
		RowOffsets:    []int64{},
	}
	chars := make([]byte, cells)
	for c := 0; c < cells; c++ {
		lo := int64(c) * total / int64(cells)
		hi := int64(c+1) * total / int64(cells)
		if hi <= lo {
			hi = lo + 1
		}
		if hi > total {
			hi = total
		}
		ch := byte('.')
		for b := lo; b < hi; b++ {
			switch statuses[b] {
			case statusFailed:
				ch = 'X'
			case statusSlow:
				if ch != 'X' {
					ch = '!'
				}
			}
		}
		chars[c] = ch
	}
	for start := 0; start < cells; start += mapRowWidth {
		end := start + mapRowWidth
		if end > cells {
			end = cells
		}
		firstBlock := int64(start) * total / int64(cells)
		m.Rows = append(m.Rows, string(chars[start:end]))
		m.RowOffsets = append(m.RowOffsets, firstBlock*blockSize)
	}
	return m
}

func printMap(m *surfaceMap) {
	if m == nil {
		return
	}
	fmt.Printf("Surface map  (%d cells, %.2f blocks per cell, %d chars per row)\n",
		m.Cells, m.BlocksPerCell, m.RowWidth)
	fmt.Printf("  legend: %s\n", m.Legend)
	for i, row := range m.Rows {
		fmt.Printf("  0x%010X  %s\n", m.RowOffsets[i], row)
	}
}

// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------

type scanOpts struct {
	blockSize int64
	slowMS    float64
	top       int
	save      string
}

func runScan(t *target, o scanOpts) (*scanRecord, int) {
	total := (t.size + o.blockSize - 1) / o.blockSize
	if total > maxBlocks {
		fmt.Fprintf(os.Stderr, "%s: %s would need %d blocks at %d bytes each, which is more than the %d-block limit; use a larger --block\n",
			toolName, t.path, total, o.blockSize, int64(maxBlocks))
		return nil, exitUsage
	}
	fp, err := t.fingerprint()
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %s: cannot fingerprint image: %v\n", toolName, t.path, err)
		return nil, exitUsage
	}

	buf := make([]byte, o.blockSize)
	times := make([]float64, total)
	statuses := make([]string, total)
	errText := make(map[int64]string)
	lengths := make([]int64, total)

	var bytesRead int64
	wall := time.Now()
	for i := int64(0); i < total; i++ {
		off := i * o.blockSize
		want := o.blockSize
		if off+want > t.size {
			want = t.size - off
		}
		lengths[i] = want
		ms, rerr := t.timedRead(buf[:want], off)
		times[i] = ms
		if rerr != "" {
			statuses[i] = statusFailed
			errText[i] = rerr
		} else {
			statuses[i] = statusOK
			bytesRead += want
		}
	}
	elapsed := float64(time.Since(wall).Nanoseconds()) / 1e6

	// Statistics are computed over blocks that actually delivered their data.
	good := make([]float64, 0, total)
	for i := int64(0); i < total; i++ {
		if statuses[i] == statusOK {
			good = append(good, times[i])
		}
	}
	st := statsOf(good, elapsed)

	mode := "fixed"
	thr := o.slowMS
	desc := fmt.Sprintf("fixed: a block is SLOW when its read takes longer than %.3f ms (--slow-ms)", thr)
	if o.slowMS <= 0 {
		mode = "adaptive"
		thr = adaptiveMultiple * st.Median
		if thr < adaptiveFloorMS {
			thr = adaptiveFloorMS
		}
		desc = fmt.Sprintf("adaptive: a block is SLOW when its read takes longer than %.3f ms = max(%.0f x median %.3f ms, %.1f ms floor)",
			thr, adaptiveMultiple, st.Median, adaptiveFloorMS)
	}

	var counts classCounts
	for i := int64(0); i < total; i++ {
		if statuses[i] == statusOK && times[i] > thr {
			statuses[i] = statusSlow
		}
		switch statuses[i] {
		case statusOK:
			counts.OK++
		case statusSlow:
			counts.Slow++
		default:
			counts.Failed++
		}
	}

	mk := func(i int64) blockResult {
		return blockResult{
			Index:  i,
			Offset: i * o.blockSize,
			Length: lengths[i],
			MS:     times[i],
			Status: statuses[i],
			Error:  errText[i],
		}
	}

	flagged := []blockResult{}
	for i := int64(0); i < total; i++ {
		if statuses[i] != statusOK {
			flagged = append(flagged, mk(i))
		}
	}

	order := make([]int64, total)
	for i := int64(0); i < total; i++ {
		order[i] = i
	}
	sort.SliceStable(order, func(a, b int) bool { return times[order[a]] > times[order[b]] })
	topN := o.top
	if int64(topN) > total {
		topN = int(total)
	}
	slowest := []blockResult{}
	for i := 0; i < topN; i++ {
		slowest = append(slowest, mk(order[i]))
	}

	rec := &scanRecord{
		Tool:          toolName,
		Format:        scanFormat,
		ScannedAt:     time.Now().UTC().Format(time.RFC3339Nano),
		Image:         t.path,
		ImageSize:     t.size,
		Fingerprint:   fp,
		BlockSize:     o.blockSize,
		TotalBlocks:   total,
		BytesRead:     bytesRead,
		ThresholdMode: mode,
		SlowMS:        thr,
		ThresholdDesc: desc,
		Counts:        counts,
		Timing:        st,
		Flagged:       flagged,
		Slowest:       slowest,
		Map:           buildMap(statuses, o.blockSize),
	}

	rc := 0
	if counts.Failed > 0 {
		rc = exitFindings
	}
	if o.save != "" {
		if err := saveScan(rec, o.save, t); err != nil {
			fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
			return rec, exitUsage
		}
	}
	return rec, rc
}

// saveScan writes the scan record to the user-nominated path. This is the only
// file sectorpilot ever writes, and it refuses to write over the image itself.
func saveScan(rec *scanRecord, path string, t *target) error {
	abs := path
	if a, err := filepath.Abs(path); err == nil {
		abs = a
	}
	if filepath.Clean(abs) == filepath.Clean(t.path) {
		return fmt.Errorf("refusing to --save over the image being scanned (%s)", t.path)
	}
	if st, err := os.Stat(path); err == nil {
		if ist, ierr := t.f.Stat(); ierr == nil && os.SameFile(st, ist) {
			return fmt.Errorf("refusing to --save over the image being scanned (%s)", t.path)
		}
		if st.IsDir() {
			return fmt.Errorf("--save target %s is a directory", path)
		}
	}
	data, err := json.MarshalIndent(rec, "", "  ")
	if err != nil {
		return fmt.Errorf("encoding scan: %w", err)
	}
	data = append(data, '\n')
	if err := os.WriteFile(path, data, 0o644); err != nil {
		return fmt.Errorf("saving scan: %w", err)
	}
	return nil
}

func pct(n, d int64) float64 {
	if d == 0 {
		return 0
	}
	return 100 * float64(n) / float64(d)
}

func printScan(rec *scanRecord, savePath string) {
	fmt.Printf("Image:        %s\n", rec.Image)
	fmt.Printf("Size:         %s (%d bytes)\n", humanBytes(rec.ImageSize), rec.ImageSize)
	fmt.Printf("Fingerprint:  %s\n", rec.Fingerprint)
	fmt.Printf("Block size:   %s (%d bytes)\n", humanBytes(rec.BlockSize), rec.BlockSize)
	fmt.Printf("Blocks:       %d   = ceil(%d / %d)\n", rec.TotalBlocks, rec.ImageSize, rec.BlockSize)
	fmt.Printf("Bytes read:   %d (%.2f%% of the file)\n", rec.BytesRead, pct(rec.BytesRead, rec.ImageSize))
	tp := ""
	if rec.Timing.Elapsed > 0 {
		bps := float64(rec.BytesRead) / (rec.Timing.Elapsed / 1000)
		tp = fmt.Sprintf("   (%s/s)", humanBytes(int64(bps)))
	}
	fmt.Printf("Elapsed:      %.3f s (%.1f ms)%s\n", rec.Timing.Elapsed/1000, rec.Timing.Elapsed, tp)
	fmt.Println()

	fmt.Println("Per-block read time (ms)")
	fmt.Printf("  min %.4f   median %.4f   p95 %.4f   max %.4f   mean %.4f   over %d %s\n",
		rec.Timing.Min, rec.Timing.Median, rec.Timing.P95, rec.Timing.Max, rec.Timing.Mean,
		rec.Timing.Measured, blocksOf(rec.Timing.Measured))
	fmt.Printf("  slow threshold %.4f ms  [%s]\n", rec.SlowMS, rec.ThresholdDesc)
	fmt.Println()

	fmt.Println("Block health")
	fmt.Printf("  OK      %8d  (%6.2f%%)\n", rec.Counts.OK, pct(rec.Counts.OK, rec.TotalBlocks))
	fmt.Printf("  SLOW    %8d  (%6.2f%%)\n", rec.Counts.Slow, pct(rec.Counts.Slow, rec.TotalBlocks))
	fmt.Printf("  FAILED  %8d  (%6.2f%%)\n", rec.Counts.Failed, pct(rec.Counts.Failed, rec.TotalBlocks))
	fmt.Println()

	printMap(rec.Map)
	fmt.Println()

	if len(rec.Slowest) > 0 {
		fmt.Printf("Slowest %d %s\n", len(rec.Slowest), blocksOf(int64(len(rec.Slowest))))
		fmt.Printf("  %-5s %-12s %-16s %-10s %-12s %s\n", "rank", "block", "offset", "length", "time", "status")
		for i, b := range rec.Slowest {
			fmt.Printf("  %-5d %-12d %-16d %-10d %-12s %s\n",
				i+1, b.Index, b.Offset, b.Length, fmt.Sprintf("%.4f ms", b.MS), b.Status)
		}
		fmt.Println()
	}

	if len(rec.Flagged) > 0 {
		shown := rec.Flagged
		trimmed := false
		if len(shown) > 20 {
			shown = shown[:20]
			trimmed = true
		}
		fmt.Printf("Flagged blocks (%d)\n", len(rec.Flagged))
		for _, b := range shown {
			line := fmt.Sprintf("  %-7s block %-10d offset %-16d %.4f ms", b.Status, b.Index, b.Offset, b.MS)
			if b.Error != "" {
				line += "  " + b.Error
			}
			fmt.Println(line)
		}
		if trimmed {
			fmt.Printf("  ... %d more (see --save output for the full list)\n", len(rec.Flagged)-len(shown))
		}
		fmt.Println()
	}

	switch {
	case rec.Counts.Failed > 0:
		fmt.Printf("Verdict: %d %s could not be read. Stop using this media for anything you care about and image it now.\n",
			rec.Counts.Failed, blocksOf(rec.Counts.Failed))
	case rec.Counts.Slow > 0:
		fmt.Printf("Verdict: no read errors, but %d %s read slower than %.4f ms. A slow block is a HINT, not a diagnosis: rescan when the machine is idle, then compare the two scans.\n",
			rec.Counts.Slow, blocksOf(rec.Counts.Slow), rec.SlowMS)
	default:
		fmt.Println("Verdict: every block read cleanly and within the slow threshold.")
	}
	if savePath != "" {
		fmt.Printf("Scan saved to %s\n", savePath)
	}
}

// ---------------------------------------------------------------------------
// Loading saved scans.
// ---------------------------------------------------------------------------

func loadScan(path string) (*scanRecord, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(data) == 0 {
		return nil, fmt.Errorf("%s is empty: not a sectorpilot scan file", path)
	}
	var rec scanRecord
	if err := json.Unmarshal(data, &rec); err != nil {
		return nil, fmt.Errorf("%s is not valid JSON: %v", path, err)
	}
	if rec.Tool != toolName {
		return nil, fmt.Errorf("%s is not a sectorpilot scan file (tool field is %q)", path, rec.Tool)
	}
	if rec.Format != scanFormat {
		return nil, fmt.Errorf("%s uses scan format %d, this build understands format %d", path, rec.Format, scanFormat)
	}
	if rec.BlockSize <= 0 || rec.TotalBlocks <= 0 || rec.ImageSize <= 0 {
		return nil, fmt.Errorf("%s is corrupt: block_size=%d total_blocks=%d image_size=%d", path, rec.BlockSize, rec.TotalBlocks, rec.ImageSize)
	}
	for _, b := range rec.Flagged {
		if b.Offset < 0 || b.Length <= 0 {
			return nil, fmt.Errorf("%s is corrupt: flagged block at index %d has offset %d length %d", path, b.Index, b.Offset, b.Length)
		}
	}
	return &rec, nil
}

// flaggedMap indexes the flagged blocks of a scan by byte offset. Any offset
// not present read OK at scan time.
func flaggedMap(rec *scanRecord) map[int64]blockResult {
	m := make(map[int64]blockResult, len(rec.Flagged))
	for _, b := range rec.Flagged {
		m[b.Offset] = b
	}
	return m
}

// ---------------------------------------------------------------------------
// compare
// ---------------------------------------------------------------------------

// changedBlock describes one block whose class differs between two scans.
// A scan file records per-block times only for the blocks it flagged, so the
// read time on the side where the block was OK is unknown and stays null.
type changedBlock struct {
	Index    int64    `json:"index"`
	Offset   int64    `json:"offset"`
	Before   string   `json:"before"`
	After    string   `json:"after"`
	BeforeMS *float64 `json:"before_ms"`
	AfterMS  *float64 `json:"after_ms"`
	Error    string   `json:"error,omitempty"`
}

type scanSummary struct {
	File        string      `json:"file"`
	Image       string      `json:"image"`
	ScannedAt   string      `json:"scanned_at"`
	Fingerprint string      `json:"fingerprint"`
	BlockSize   int64       `json:"block_size"`
	TotalBlocks int64       `json:"total_blocks"`
	Counts      classCounts `json:"counts"`
	SlowMS      float64     `json:"slow_ms"`
	Median      float64     `json:"median_ms"`
	P95         float64     `json:"p95_ms"`
}

type compareResult struct {
	Before     scanSummary    `json:"before"`
	After      scanSummary    `json:"after"`
	Notes      []string       `json:"notes"`
	NewFailed  []changedBlock `json:"new_failed"`
	NewSlow    []changedBlock `json:"new_slow"`
	Recovered  []changedBlock `json:"recovered"`
	StillBad   []changedBlock `json:"still_bad"`
	Improved   []changedBlock `json:"partially_recovered"`
	Spreading  bool           `json:"spreading"`
	Verdict    string         `json:"verdict"`
	NetChangeS int64          `json:"net_slow_change"`
	NetChangeF int64          `json:"net_failed_change"`
}

func summarize(file string, rec *scanRecord) scanSummary {
	return scanSummary{
		File:        file,
		Image:       rec.Image,
		ScannedAt:   rec.ScannedAt,
		Fingerprint: rec.Fingerprint,
		BlockSize:   rec.BlockSize,
		TotalBlocks: rec.TotalBlocks,
		Counts:      rec.Counts,
		SlowMS:      rec.SlowMS,
		Median:      rec.Timing.Median,
		P95:         rec.Timing.P95,
	}
}

func compareScans(beforeFile, afterFile string, before, after *scanRecord) (*compareResult, error) {
	if before.BlockSize != after.BlockSize {
		return nil, fmt.Errorf("block sizes differ (%d vs %d bytes): the two scans do not describe the same blocks, rescan with a matching --block",
			before.BlockSize, after.BlockSize)
	}
	res := &compareResult{
		Before:    summarize(beforeFile, before),
		After:     summarize(afterFile, after),
		Notes:     []string{},
		NewFailed: []changedBlock{},
		NewSlow:   []changedBlock{},
		Recovered: []changedBlock{},
		StillBad:  []changedBlock{},
		Improved:  []changedBlock{},
	}
	if before.Fingerprint != after.Fingerprint {
		res.Notes = append(res.Notes, "the two scans have different image fingerprints: the file changed between scans, so offsets may no longer hold the same data")
	}
	if before.ImageSize != after.ImageSize {
		res.Notes = append(res.Notes, fmt.Sprintf("image size changed between scans (%d -> %d bytes)", before.ImageSize, after.ImageSize))
	}
	if before.SlowMS != after.SlowMS {
		res.Notes = append(res.Notes, fmt.Sprintf("slow thresholds differ (%.4f ms -> %.4f ms): some status changes may reflect the threshold, not the media", before.SlowMS, after.SlowMS))
	}

	bm := flaggedMap(before)
	am := flaggedMap(after)
	offsets := make([]int64, 0, len(bm)+len(am))
	seen := make(map[int64]bool, len(bm)+len(am))
	for off := range bm {
		if !seen[off] {
			seen[off] = true
			offsets = append(offsets, off)
		}
	}
	for off := range am {
		if !seen[off] {
			seen[off] = true
			offsets = append(offsets, off)
		}
	}
	sort.Slice(offsets, func(i, j int) bool { return offsets[i] < offsets[j] })

	for _, off := range offsets {
		b, hasB := bm[off]
		a, hasA := am[off]
		bs, as := statusOK, statusOK
		if hasB {
			bs = b.Status
		}
		if hasA {
			as = a.Status
		}
		if bs == as {
			if bs != statusOK {
				res.StillBad = append(res.StillBad, mkChange(off, before.BlockSize, bs, as, b, a, hasB, hasA))
			}
			continue
		}
		ch := mkChange(off, before.BlockSize, bs, as, b, a, hasB, hasA)
		switch {
		case as == statusFailed:
			res.NewFailed = append(res.NewFailed, ch)
		case as == statusSlow && bs == statusOK:
			res.NewSlow = append(res.NewSlow, ch)
		case as == statusSlow && bs == statusFailed:
			res.Improved = append(res.Improved, ch)
		case as == statusOK:
			res.Recovered = append(res.Recovered, ch)
		}
	}

	res.NetChangeS = after.Counts.Slow - before.Counts.Slow
	res.NetChangeF = after.Counts.Failed - before.Counts.Failed

	switch {
	case len(res.NewFailed) > 0:
		res.Spreading = true
		res.Verdict = fmt.Sprintf("DEGRADING - %d %s that did not fail before now fail outright. Damage is spreading; image the media immediately.",
			len(res.NewFailed), blocksOf(int64(len(res.NewFailed))))
	case len(res.NewSlow) > len(res.Recovered) && len(res.NewSlow) > 0:
		res.Spreading = true
		res.Verdict = fmt.Sprintf("DEGRADING - %d newly SLOW %s against %d recovered. More of the surface is struggling than last time. Confirm with 'sectorpilot verify' on an idle machine before concluding.",
			len(res.NewSlow), blocksOf(int64(len(res.NewSlow))), len(res.Recovered))
	case len(res.NewSlow) > 0 && len(res.NewSlow) <= len(res.Recovered):
		res.Verdict = fmt.Sprintf("MIXED - %d newly SLOW %s but %d recovered. This pattern usually means load or caching noise rather than spreading damage.",
			len(res.NewSlow), blocksOf(int64(len(res.NewSlow))), len(res.Recovered))
	case len(res.Recovered) > 0 || len(res.Improved) > 0:
		res.Verdict = fmt.Sprintf("IMPROVED - %d %s recovered and nothing got worse. The earlier readings were most likely load, not media.",
			len(res.Recovered)+len(res.Improved), blocksOf(int64(len(res.Recovered)+len(res.Improved))))
	case len(res.StillBad) > 0:
		res.Verdict = fmt.Sprintf("STABLE - no change, but %d %s remain flagged in both scans.",
			len(res.StillBad), blocksOf(int64(len(res.StillBad))))
	default:
		res.Verdict = "STABLE - no block changed class between the two scans."
	}
	return res, nil
}

func mkChange(off, blockSize int64, bs, as string, b, a blockResult, hasB, hasA bool) changedBlock {
	ch := changedBlock{
		Index:  off / blockSize,
		Offset: off,
		Before: bs,
		After:  as,
	}
	if hasB {
		ms := b.MS
		ch.BeforeMS = &ms
	}
	if hasA {
		ms := a.MS
		ch.AfterMS = &ms
	}
	if a.Error != "" {
		ch.Error = a.Error
	}
	return ch
}

// msText renders an optional read time; scans do not record a time for blocks
// that read OK, so those print as n/a rather than a misleading 0.
func msText(p *float64) string {
	if p == nil {
		return "n/a"
	}
	return fmt.Sprintf("%.4f ms", *p)
}

func printCompare(res *compareResult) {
	side := func(label string, s scanSummary) {
		fmt.Printf("%-8s %s\n", label, s.File)
		fmt.Printf("         image %s\n", s.Image)
		fmt.Printf("         scanned %s   %d blocks of %d bytes\n", s.ScannedAt, s.TotalBlocks, s.BlockSize)
		fmt.Printf("         OK %d  SLOW %d  FAILED %d   median %.4f ms  p95 %.4f ms  threshold %.4f ms\n",
			s.Counts.OK, s.Counts.Slow, s.Counts.Failed, s.Median, s.P95, s.SlowMS)
	}
	side("Before:", res.Before)
	side("After:", res.After)
	fmt.Println()
	for _, n := range res.Notes {
		fmt.Printf("Note: %s\n", n)
	}
	if len(res.Notes) > 0 {
		fmt.Println()
	}

	group := func(title string, list []changedBlock) {
		fmt.Printf("%s (%d)\n", title, len(list))
		shown := list
		trimmed := 0
		if len(shown) > 20 {
			trimmed = len(shown) - 20
			shown = shown[:20]
		}
		for _, c := range shown {
			line := fmt.Sprintf("  block %-10d offset %-16d %-6s -> %-6s   %-12s -> %s",
				c.Index, c.Offset, c.Before, c.After, msText(c.BeforeMS), msText(c.AfterMS))
			if c.Error != "" {
				line += "  " + c.Error
			}
			fmt.Println(line)
		}
		if trimmed > 0 {
			fmt.Printf("  ... %d more\n", trimmed)
		}
	}
	group("NEW FAILED", res.NewFailed)
	group("NEW SLOW", res.NewSlow)
	group("RECOVERED", res.Recovered)
	group("PARTIALLY RECOVERED", res.Improved)
	group("STILL BAD", res.StillBad)
	fmt.Println()
	fmt.Println("(n/a means that scan recorded no time for the block because it read OK)")
	fmt.Printf("Net change: SLOW %+d, FAILED %+d\n", res.NetChangeS, res.NetChangeF)
	fmt.Printf("Spreading:  %v\n", res.Spreading)
	fmt.Printf("Verdict:    %s\n", res.Verdict)
}

// ---------------------------------------------------------------------------
// verify
// ---------------------------------------------------------------------------

type verifyBlock struct {
	Index      int64   `json:"index"`
	Offset     int64   `json:"offset"`
	Length     int64   `json:"length"`
	Previous   string  `json:"previous"`
	Current    string  `json:"current"`
	PreviousMS float64 `json:"previous_ms"`
	MS         float64 `json:"ms"`
	Error      string  `json:"error,omitempty"`
}

type verifyResult struct {
	Image       string        `json:"image"`
	ScanFile    string        `json:"scan_file"`
	ScannedAt   string        `json:"scan_taken_at"`
	BlockSize   int64         `json:"block_size"`
	TotalBlocks int64         `json:"total_blocks_in_scan"`
	Rechecked   int64         `json:"blocks_rechecked"`
	BytesRead   int64         `json:"bytes_read"`
	SlowMS      float64       `json:"slow_ms"`
	ElapsedMS   float64       `json:"elapsed_ms"`
	Counts      classCounts   `json:"counts"`
	Blocks      []verifyBlock `json:"blocks"`
	Verdict     string        `json:"verdict"`
}

func runVerify(t *target, scanFile string, rec *scanRecord) (*verifyResult, int) {
	fp, err := t.fingerprint()
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %s: cannot fingerprint image: %v\n", toolName, t.path, err)
		return nil, exitUsage
	}
	if fp != rec.Fingerprint {
		fmt.Fprintf(os.Stderr, "%s: %s was taken on a different image: scan fingerprint %s, %s is %s (%d bytes vs %d bytes recorded)\n",
			toolName, scanFile, rec.Fingerprint, t.path, fp, t.size, rec.ImageSize)
		return nil, exitUsage
	}

	flagged := make([]blockResult, len(rec.Flagged))
	copy(flagged, rec.Flagged)
	sort.Slice(flagged, func(i, j int) bool { return flagged[i].Offset < flagged[j].Offset })

	res := &verifyResult{
		Image:       t.path,
		ScanFile:    scanFile,
		ScannedAt:   rec.ScannedAt,
		BlockSize:   rec.BlockSize,
		TotalBlocks: rec.TotalBlocks,
		Rechecked:   int64(len(flagged)),
		SlowMS:      rec.SlowMS,
		Blocks:      []verifyBlock{},
	}
	buf := make([]byte, rec.BlockSize)
	wall := time.Now()
	for _, b := range flagged {
		want := b.Length
		if want > rec.BlockSize {
			want = rec.BlockSize
		}
		if b.Offset+want > t.size {
			want = t.size - b.Offset
		}
		vb := verifyBlock{
			Index:      b.Index,
			Offset:     b.Offset,
			Length:     want,
			Previous:   b.Status,
			PreviousMS: b.MS,
		}
		if want <= 0 {
			vb.Current = statusFailed
			vb.Error = fmt.Sprintf("offset %d is past the end of the file (%d bytes)", b.Offset, t.size)
			res.Counts.Failed++
			res.Blocks = append(res.Blocks, vb)
			continue
		}
		ms, rerr := t.timedRead(buf[:want], b.Offset)
		vb.MS = ms
		switch {
		case rerr != "":
			vb.Current = statusFailed
			vb.Error = rerr
			res.Counts.Failed++
		case ms > rec.SlowMS:
			vb.Current = statusSlow
			res.Counts.Slow++
			res.BytesRead += want
		default:
			vb.Current = statusOK
			res.Counts.OK++
			res.BytesRead += want
		}
		res.Blocks = append(res.Blocks, vb)
	}
	res.ElapsedMS = float64(time.Since(wall).Nanoseconds()) / 1e6

	switch {
	case len(flagged) == 0:
		res.Verdict = "NOTHING TO CHECK - the saved scan flagged no blocks."
	case res.Counts.Failed > 0:
		res.Verdict = fmt.Sprintf("CONFIRMED BAD - %d of %d rechecked %s still cannot be read.",
			res.Counts.Failed, len(flagged), blocksOf(int64(len(flagged))))
	case res.Counts.Slow > 0 && res.Counts.OK > 0:
		res.Verdict = fmt.Sprintf("PARTIALLY CONFIRMED - %d %s still read slowly, %d now read within the threshold.",
			res.Counts.Slow, blocksOf(res.Counts.Slow), res.Counts.OK)
	case res.Counts.Slow > 0:
		res.Verdict = fmt.Sprintf("CONFIRMED SLOW - all %d rechecked %s are still above %.4f ms.",
			res.Counts.Slow, blocksOf(res.Counts.Slow), rec.SlowMS)
	default:
		res.Verdict = fmt.Sprintf("NOT REPRODUCED - all %d previously flagged %s now read within %.4f ms. The earlier readings were most likely load, not media.",
			len(flagged), blocksOf(int64(len(flagged))), rec.SlowMS)
	}

	rc := 0
	if res.Counts.Failed > 0 {
		rc = exitFindings
	}
	return res, rc
}

func printVerify(res *verifyResult) {
	fmt.Printf("Image:        %s\n", res.Image)
	fmt.Printf("Scan file:    %s  (taken %s)\n", res.ScanFile, res.ScannedAt)
	fmt.Printf("Block size:   %d bytes\n", res.BlockSize)
	fmt.Printf("Rechecking:   %d of %d %s flagged by that scan\n", res.Rechecked, res.TotalBlocks, blocksOf(res.TotalBlocks))
	fmt.Printf("Bytes read:   %d  (a full scan would have read %d)\n", res.BytesRead, res.TotalBlocks*res.BlockSize)
	fmt.Printf("Threshold:    %.4f ms (inherited from the saved scan)\n", res.SlowMS)
	fmt.Printf("Elapsed:      %.3f ms\n", res.ElapsedMS)
	fmt.Println()
	if len(res.Blocks) > 0 {
		fmt.Printf("  %-12s %-16s %-10s %-14s %-10s %s\n", "block", "offset", "was", "was time", "now", "now time")
		shown := res.Blocks
		trimmed := 0
		if len(shown) > 40 {
			trimmed = len(shown) - 40
			shown = shown[:40]
		}
		for _, b := range shown {
			line := fmt.Sprintf("  %-12d %-16d %-10s %-14s %-10s %.4f ms",
				b.Index, b.Offset, b.Previous, fmt.Sprintf("%.4f ms", b.PreviousMS), b.Current, b.MS)
			if b.Error != "" {
				line += "  " + b.Error
			}
			fmt.Println(line)
		}
		if trimmed > 0 {
			fmt.Printf("  ... %d more\n", trimmed)
		}
		fmt.Println()
	}
	fmt.Printf("Now: OK %d  SLOW %d  FAILED %d\n", res.Counts.OK, res.Counts.Slow, res.Counts.Failed)
	fmt.Printf("Verdict: %s\n", res.Verdict)
}

// ---------------------------------------------------------------------------
// Plumbing.
// ---------------------------------------------------------------------------

func emitJSON(v any) int {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(os.Stderr, "%s: encoding JSON: %v\n", toolName, err)
		return exitUsage
	}
	return 0
}

func usage(w io.Writer) {
	fmt.Fprint(w, `sectorpilot - read-only block-level surface scan and health map

USAGE
  sectorpilot scan    <image> [--block 64KB] [--slow-ms N] [--save scan.json] [--top N] [--json]
  sectorpilot compare --before a.json --after b.json [--json]
  sectorpilot verify  <image> --scan scan.json [--json]

COMMANDS
  scan      Read the image block by block, time every read, and classify each
            block OK / SLOW / FAILED. Prints counts, median and p95 read times,
            the slowest blocks with their byte offsets, and an ASCII map of the
            whole surface.
  compare   Diff two saved scans: which blocks got worse, which recovered, and
            whether damage is spreading.
  verify    Re-read only the blocks a saved scan flagged, to confirm quickly
            whether they are still bad.
  help      Show this message.

OPTIONS
  --block <size>    Block size for scan. Accepts 4096, 64KB, 1MB, 1GB.
                    Suffixes are powers of 1024. Default 64KB.
                    Must be between 512 bytes and 64 MiB.
  --slow-ms <ms>    Fixed slow threshold in milliseconds; fractions allowed
                    (e.g. 0.05). When omitted or 0, an adaptive threshold of
                    max(8 x median read time, 1.0 ms) is used instead.
  --save <path>     Write the scan to a JSON file for later compare/verify.
  --top <n>         How many slowest blocks to list. Default 10.
  --before <path>   compare: the earlier scan file.
  --after <path>    compare: the later scan file.
  --scan <path>     verify: the saved scan whose flagged blocks to re-read.
  --json            Emit machine-readable JSON instead of text.
  -h, --help        Show this message.

EXIT STATUS
  0   success, nothing failed
  1   usage error, or the image or scan file could not be read
  2   scan: at least one block FAILED
      compare: at least one block that did not fail before now fails
      verify: at least one rechecked block still FAILED

NOTES
  sectorpilot scans FILES AND IMAGES, not raw block devices. It never writes
  to the image: the image is opened read-only and only ever read. The single
  file it can write is the --save destination, which it refuses to point at
  the image. A SLOW block is a hint, not a diagnosis.

EXAMPLES
  sectorpilot scan disk.img --save monday.json
  sectorpilot scan disk.img --block 1MB --slow-ms 50
  sectorpilot compare --before monday.json --after friday.json
  sectorpilot verify disk.img --scan monday.json --json
`)
}

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

func main() {
	os.Exit(run(os.Args[1:]))
}

func run(argv []string) int {
	if len(argv) == 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 0
		}
		usage(os.Stderr)
		return exitUsage
	}
	if isHelpArg(argv[0]) {
		usage(os.Stdout)
		return 0
	}

	cmd := argv[0]
	switch cmd {
	case "scan":
		return runScanCmd(argv[1:])
	case "compare":
		return runCompareCmd(argv[1:])
	case "verify":
		return runVerifyCmd(argv[1:])
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", toolName, cmd)
		usage(os.Stderr)
		return exitUsage
	}
}

// parseErr reports a flag parsing failure the way every Techlosoft tool does.
func parseErr(err error) (int, bool) {
	if err == flag.ErrHelp {
		usage(os.Stdout)
		return 0, true
	}
	fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
	usage(os.Stderr)
	return exitUsage, true
}

func onePositional(cmd string, rest []string, what string) (string, int, bool) {
	if len(rest) == 0 {
		fmt.Fprintf(os.Stderr, "%s: %s requires an %s\n\n", toolName, cmd, what)
		usage(os.Stderr)
		return "", exitUsage, false
	}
	if len(rest) > 1 {
		fmt.Fprintf(os.Stderr, "%s: %s takes exactly one %s (got %d: %s)\n\n",
			toolName, cmd, what, len(rest), strings.Join(rest, ", "))
		usage(os.Stderr)
		return "", exitUsage, false
	}
	return rest[0], 0, true
}

func runScanCmd(argv []string) int {
	args := reorderFlags(argv, map[string]bool{"block": true, "slow-ms": true, "save": true, "top": true})
	fs := flag.NewFlagSet("scan", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	block := fs.String("block", "64KB", "block size")
	slowMS := fs.Float64("slow-ms", 0, "slow threshold in milliseconds")
	save := fs.String("save", "", "save scan to file")
	top := fs.Int("top", 10, "how many slowest blocks to list")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		rc, _ := parseErr(err)
		return rc
	}
	path, rc, ok := onePositional("scan", fs.Args(), "image path")
	if !ok {
		return rc
	}
	blockSize, err := parseSize(*block)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: invalid --block %q: %v\n", toolName, *block, err)
		return exitUsage
	}
	if blockSize < minBlockSize || blockSize > maxBlockSize {
		fmt.Fprintf(os.Stderr, "%s: invalid --block %q (%d bytes): must be between %d bytes and %s\n",
			toolName, *block, blockSize, minBlockSize, humanBytes(maxBlockSize))
		return exitUsage
	}
	if *slowMS < 0 {
		fmt.Fprintf(os.Stderr, "%s: invalid --slow-ms %g: must not be negative\n", toolName, *slowMS)
		return exitUsage
	}
	if *top < 1 || *top > 1000 {
		fmt.Fprintf(os.Stderr, "%s: invalid --top %d: must be between 1 and 1000\n", toolName, *top)
		return exitUsage
	}

	t, err := openTarget(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	defer t.Close()

	rec, rc := runScan(t, scanOpts{blockSize: blockSize, slowMS: *slowMS, top: *top, save: *save})
	if rec == nil {
		return rc
	}
	if *asJSON {
		if jrc := emitJSON(rec); jrc != 0 {
			return jrc
		}
		return rc
	}
	printScan(rec, *save)
	return rc
}

func runCompareCmd(argv []string) int {
	args := reorderFlags(argv, map[string]bool{"before": true, "after": true})
	fs := flag.NewFlagSet("compare", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	before := fs.String("before", "", "earlier scan file")
	after := fs.String("after", "", "later scan file")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		rc, _ := parseErr(err)
		return rc
	}
	if rest := fs.Args(); len(rest) > 0 {
		fmt.Fprintf(os.Stderr, "%s: compare takes no positional arguments (got %s); use --before and --after\n\n",
			toolName, strings.Join(rest, ", "))
		usage(os.Stderr)
		return exitUsage
	}
	if *before == "" || *after == "" {
		fmt.Fprintf(os.Stderr, "%s: compare requires both --before and --after\n\n", toolName)
		usage(os.Stderr)
		return exitUsage
	}
	b, err := loadScan(*before)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: --before: %v\n", toolName, err)
		return exitUsage
	}
	a, err := loadScan(*after)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: --after: %v\n", toolName, err)
		return exitUsage
	}
	res, err := compareScans(*before, *after, b, a)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	if *asJSON {
		if jrc := emitJSON(res); jrc != 0 {
			return jrc
		}
	} else {
		printCompare(res)
	}
	if len(res.NewFailed) > 0 {
		return exitFindings
	}
	return 0
}

func runVerifyCmd(argv []string) int {
	args := reorderFlags(argv, map[string]bool{"scan": true})
	fs := flag.NewFlagSet("verify", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	scanFile := fs.String("scan", "", "saved scan file")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		rc, _ := parseErr(err)
		return rc
	}
	path, rc, ok := onePositional("verify", fs.Args(), "image path")
	if !ok {
		return rc
	}
	if *scanFile == "" {
		fmt.Fprintf(os.Stderr, "%s: verify requires --scan <scan.json>\n\n", toolName)
		usage(os.Stderr)
		return exitUsage
	}
	rec, err := loadScan(*scanFile)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: --scan: %v\n", toolName, err)
		return exitUsage
	}
	t, err := openTarget(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	defer t.Close()

	res, rc := runVerify(t, *scanFile, rec)
	if res == nil {
		return rc
	}
	if *asJSON {
		if jrc := emitJSON(res); jrc != 0 {
			return jrc
		}
		return rc
	}
	printVerify(res)
	return rc
}
