// Command mediarescue is a one-command triage tool for offline photo
// libraries: it fully decodes every image it finds, gives each file a
// single plain-language verdict (HEALTHY, TRUNCATED, CORRUPT,
// MISLABELLED, EMPTY, UNREADABLE), and can quarantine the bad ones so
// the surviving library is trustworthy.
//
// MediaRescue deliberately does NOT attempt repair; that is PhotoRevive's
// job. See README.txt for the full scope statement.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

// ---------------------------------------------------------------------
// Shared helpers (established conventions across the PhotoRevive suite)
// ---------------------------------------------------------------------

// reorderFlags works around a quirk of Go's flag package: it stops
// parsing flags at the first positional argument. This reorders args so
// all flags (and, for flags that take a value, their following value)
// come before all positional arguments, regardless of where the user
// typed them on the command line.
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...)
}

// humanBytes renders a byte count as a human-readable size (KiB, MiB, ...).
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])
}

// ---------------------------------------------------------------------
// Verdict classes
// ---------------------------------------------------------------------

// The six verdicts MediaRescue can hand down for a candidate file.
const (
	ClassHealthy     = "HEALTHY"
	ClassTruncated   = "TRUNCATED"
	ClassCorrupt     = "CORRUPT"
	ClassMislabelled = "MISLABELLED"
	ClassEmpty       = "EMPTY"
	ClassUnreadable  = "UNREADABLE"
)

// classOrder fixes the order verdicts appear in every summary so reports
// are stable and diffable.
var classOrder = []string{
	ClassHealthy,
	ClassTruncated,
	ClassCorrupt,
	ClassMislabelled,
	ClassEmpty,
	ClassUnreadable,
}

// selectableClasses are the verdicts that --classes accepts. HEALTHY is
// deliberately absent: MediaRescue will not move a file it just declared
// healthy.
var selectableClasses = map[string]string{
	"truncated":   ClassTruncated,
	"corrupt":     ClassCorrupt,
	"mislabelled": ClassMislabelled,
	"mislabeled":  ClassMislabelled,
	"empty":       ClassEmpty,
	"unreadable":  ClassUnreadable,
}

// ---------------------------------------------------------------------
// Format detection
// ---------------------------------------------------------------------

// extFormats maps the file extensions MediaRescue triages to the logical
// image format the extension claims the file is.
var extFormats = map[string]string{
	".jpg":  "jpeg",
	".jpeg": "jpeg",
	".png":  "png",
	".gif":  "gif",
}

// magicPeek is how many leading bytes are read for magic-number sniffing.
const magicPeek = 12

// sniffFormat identifies the real format of a file from its leading
// bytes alone, ignoring the file name entirely. It returns "" when the
// bytes match none of the formats MediaRescue understands.
func sniffFormat(header []byte) string {
	switch {
	case len(header) >= 8 &&
		header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 &&
		header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A:
		return "png"
	case len(header) >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF:
		return "jpeg"
	case len(header) >= 6 && string(header[:6]) == "GIF87a":
		return "gif"
	case len(header) >= 6 && string(header[:6]) == "GIF89a":
		return "gif"
	}
	return ""
}

// ---------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------

// FileResult is the verdict for one candidate file.
type FileResult struct {
	Path        string `json:"path"`
	Rel         string `json:"rel_path"`
	Class       string `json:"class"`
	Ext         string `json:"ext"`
	ExtFormat   string `json:"ext_format"`
	MagicFormat string `json:"magic_format,omitempty"`
	Format      string `json:"format,omitempty"`
	Width       int    `json:"width,omitempty"`
	Height      int    `json:"height,omitempty"`
	Size        int64  `json:"size_bytes"`
	SizeHuman   string `json:"size_human"`
	Detail      string `json:"detail,omitempty"`
}

// hasTerminator reports whether a file still carries the end-of-stream
// marker its format requires: PNG's IEND chunk, JPEG's FF D9 EOI marker,
// GIF's 0x3B trailer.
//
// This is what separates TRUNCATED from CORRUPT, and it is a structural
// test rather than an error-string test: Go's image decoders report a
// file that ran out of bytes with an ordinary format error ("not enough
// pixel data", "short Huffman data"), which on its own is
// indistinguishable from internal damage. A file whose tail marker is
// gone genuinely lost its ending; a file whose tail marker is intact is
// complete but damaged somewhere inside.
//
// It is consulted only after a decode has already failed, so a healthy
// file with unusual trailing bytes can never be mislabelled by it.
func hasTerminator(f *os.File, format string, size int64) bool {
	const tailLen = 12
	n := int64(tailLen)
	if size < n {
		n = size
	}
	if n <= 0 {
		return false
	}
	tail := make([]byte, n)
	if _, err := f.ReadAt(tail, size-n); err != nil && !errors.Is(err, io.EOF) {
		return false
	}
	switch format {
	case "png":
		// IEND is the final chunk; allow for its 4-byte CRC and any
		// trailing padding within the tail window.
		return strings.Contains(string(tail), "IEND")
	case "jpeg":
		// FF D9 (End Of Image); some encoders append small padding.
		for i := 0; i+1 < len(tail); i++ {
			if tail[i] == 0xFF && tail[i+1] == 0xD9 {
				return true
			}
		}
		return false
	case "gif":
		return tail[len(tail)-1] == 0x3B
	}
	return false
}

// classify renders the verdict for a single path. It is strictly
// read-only: the file is opened for reading, decoded, and closed.
//
// Precedence is deliberate. Damage outranks naming: a file that is both
// misnamed and broken is reported as broken, because that is the verdict
// that decides whether it can still be used.
func classify(path, rel string) FileResult {
	ext := strings.ToLower(filepath.Ext(path))
	res := FileResult{
		Path:      filepath.ToSlash(path),
		Rel:       filepath.ToSlash(rel),
		Ext:       ext,
		ExtFormat: extFormats[ext],
	}

	fi, err := os.Stat(path)
	if err != nil {
		res.Class = ClassUnreadable
		res.Detail = fmt.Sprintf("cannot stat file: %v", err)
		res.SizeHuman = humanBytes(0)
		return res
	}
	res.Size = fi.Size()
	res.SizeHuman = humanBytes(fi.Size())

	if fi.Size() == 0 {
		res.Class = ClassEmpty
		res.Detail = "file is zero bytes; there is no image data at all"
		return res
	}

	f, err := os.Open(path)
	if err != nil {
		res.Class = ClassUnreadable
		res.Detail = fmt.Sprintf("cannot open file: %v", err)
		return res
	}
	defer f.Close()

	header := make([]byte, magicPeek)
	n, err := io.ReadFull(f, header)
	if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
		res.Class = ClassUnreadable
		res.Detail = fmt.Sprintf("cannot read file header: %v", err)
		return res
	}
	res.MagicFormat = sniffFormat(header[:n])

	if _, err := f.Seek(0, io.SeekStart); err != nil {
		res.Class = ClassUnreadable
		res.Detail = fmt.Sprintf("cannot seek file: %v", err)
		return res
	}

	// The whole point of MediaRescue: a full pixel decode, not a header
	// peek. image.Decode reads and decompresses the entire image stream.
	img, format, decErr := image.Decode(f)
	if decErr != nil {
		var pathErr *fs.PathError
		if errors.As(decErr, &pathErr) {
			res.Class = ClassUnreadable
			res.Detail = fmt.Sprintf("I/O error while decoding: %v", decErr)
			return res
		}
		if res.MagicFormat != "" && !hasTerminator(f, res.MagicFormat, res.Size) {
			res.Class = ClassTruncated
			res.Detail = fmt.Sprintf("valid %s header, but the file stops before the %s end-of-file marker (%v)",
				res.MagicFormat, strings.ToUpper(res.MagicFormat), decErr)
			return res
		}
		res.Class = ClassCorrupt
		if res.MagicFormat == "" {
			res.Detail = fmt.Sprintf("no recognisable PNG/JPEG/GIF magic bytes and decode failed (%v)", decErr)
		} else {
			res.Detail = fmt.Sprintf("%s header present but the image data is damaged (%v)", res.MagicFormat, decErr)
		}
		return res
	}

	b := img.Bounds()
	res.Format = format
	res.Width = b.Dx()
	res.Height = b.Dy()

	if res.MagicFormat != "" && res.ExtFormat != "" && res.MagicFormat != res.ExtFormat {
		res.Class = ClassMislabelled
		res.Detail = fmt.Sprintf("decodes fine, but it is really a %s file named %s (rename to .%s)",
			strings.ToUpper(res.MagicFormat), ext, canonicalExt(res.MagicFormat))
		return res
	}

	res.Class = ClassHealthy
	res.Detail = ""
	return res
}

// canonicalExt is the extension MediaRescue recommends for a format.
func canonicalExt(format string) string {
	switch format {
	case "jpeg":
		return "jpg"
	default:
		return format
	}
}

// ---------------------------------------------------------------------
// Walking
// ---------------------------------------------------------------------

// walkImages calls fn for every regular file under root whose extension
// is one MediaRescue triages. Without recursive, only files directly in
// root are considered. Paths under any directory in skip are ignored,
// which keeps a quarantine directory nested inside the library from
// being scanned as part of it.
func walkImages(root string, recursive bool, skip []string, fn func(path, rel string) error) error {
	return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			fmt.Fprintf(os.Stderr, "warning: %v\n", err)
			if d != nil && d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			if path != root {
				if !recursive {
					return filepath.SkipDir
				}
				for _, s := range skip {
					if sameDir(path, s) {
						return filepath.SkipDir
					}
				}
			}
			return nil
		}
		if _, ok := extFormats[strings.ToLower(filepath.Ext(path))]; !ok {
			return nil
		}
		rel, relErr := filepath.Rel(root, path)
		if relErr != nil {
			rel = filepath.Base(path)
		}
		return fn(path, rel)
	})
}

// sameDir reports whether two paths refer to the same directory.
func sameDir(a, b string) bool {
	if b == "" {
		return false
	}
	aa, err1 := filepath.Abs(a)
	bb, err2 := filepath.Abs(b)
	if err1 != nil || err2 != nil {
		return filepath.Clean(a) == filepath.Clean(b)
	}
	return filepath.Clean(aa) == filepath.Clean(bb)
}

// ---------------------------------------------------------------------
// Summaries
// ---------------------------------------------------------------------

// ClassSummary is the per-verdict roll-up printed at the end of a run.
type ClassSummary struct {
	Class      string `json:"class"`
	Count      int    `json:"count"`
	Bytes      int64  `json:"bytes"`
	BytesHuman string `json:"bytes_human"`
}

func summarize(files []FileResult) []ClassSummary {
	counts := map[string]int{}
	bytes := map[string]int64{}
	for _, f := range files {
		counts[f.Class]++
		bytes[f.Class] += f.Size
	}
	out := make([]ClassSummary, 0, len(classOrder))
	for _, c := range classOrder {
		out = append(out, ClassSummary{
			Class:      c,
			Count:      counts[c],
			Bytes:      bytes[c],
			BytesHuman: humanBytes(bytes[c]),
		})
	}
	return out
}

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

// ScanReport is the JSON shape emitted by "scan --json".
type ScanReport struct {
	Tool       string         `json:"tool"`
	Command    string         `json:"command"`
	Root       string         `json:"root"`
	Recursive  bool           `json:"recursive"`
	TotalFiles int            `json:"total_files"`
	TotalBytes int64          `json:"total_bytes"`
	Healthy    int            `json:"healthy"`
	Damaged    int            `json:"damaged"`
	Summary    []ClassSummary `json:"summary"`
	Files      []FileResult   `json:"files"`
}

func runScan(args []string) {
	fset := flag.NewFlagSet("scan", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	recursive := fset.Bool("recursive", false, "descend into subdirectories")
	jsonOut := fset.Bool("json", false, "emit machine-readable JSON")
	if err := fset.Parse(reorderFlags(args, map[string]bool{})); err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue scan: %v\n\n", err)
		fmt.Fprint(os.Stderr, scanUsage)
		os.Exit(2)
	}
	rest := fset.Args()
	if len(rest) != 1 {
		fmt.Fprint(os.Stderr, scanUsage)
		os.Exit(1)
	}
	root := rest[0]

	info, err := os.Stat(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue scan: cannot read %s: %v\n", root, err)
		os.Exit(2)
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "mediarescue scan: %s is not a directory (use \"mediarescue verify\" for a single file)\n", root)
		os.Exit(2)
	}

	report := ScanReport{Tool: "mediarescue", Command: "scan", Root: filepath.ToSlash(root), Recursive: *recursive}
	walkErr := walkImages(root, *recursive, nil, func(path, rel string) error {
		res := classify(path, rel)
		report.Files = append(report.Files, res)
		return nil
	})
	if walkErr != nil {
		fmt.Fprintf(os.Stderr, "mediarescue scan: %v\n", walkErr)
		os.Exit(2)
	}

	for _, f := range report.Files {
		report.TotalBytes += f.Size
		if f.Class == ClassHealthy {
			report.Healthy++
		} else {
			report.Damaged++
		}
	}
	report.TotalFiles = len(report.Files)
	report.Summary = summarize(report.Files)

	if *jsonOut {
		emitJSON(report)
		return
	}
	printScanReport(report)
}

func printScanReport(r ScanReport) {
	scope := "this directory only"
	if r.Recursive {
		scope = "recursive"
	}
	fmt.Println("MediaRescue - photo library triage")
	fmt.Println(strings.Repeat("=", 60))
	fmt.Printf("Library: %s  (%s)\n", r.Root, scope)
	fmt.Println()

	if r.TotalFiles == 0 {
		fmt.Println("No .png/.jpg/.jpeg/.gif files found here.")
		if !r.Recursive {
			fmt.Println("Pass --recursive to include subdirectories.")
		}
		return
	}

	fmt.Printf("%-12s %-40s %10s  %s\n", "VERDICT", "FILE", "SIZE", "DETAIL")
	fmt.Println(strings.Repeat("-", 60))
	for _, f := range r.Files {
		detail := f.Detail
		if f.Class == ClassHealthy {
			detail = fmt.Sprintf("%s %dx%d", f.Format, f.Width, f.Height)
		}
		fmt.Printf("%-12s %-40s %10s  %s\n", f.Class, f.Rel, f.SizeHuman, detail)
	}

	fmt.Println()
	fmt.Println(strings.Repeat("-", 60))
	fmt.Println("SUMMARY")
	for _, s := range r.Summary {
		fmt.Printf("  %-12s %4d file(s)  %10s\n", s.Class, s.Count, s.BytesHuman)
	}
	fmt.Printf("  %-12s %4d file(s)  %10s\n", "TOTAL", r.TotalFiles, humanBytes(r.TotalBytes))

	fmt.Println()
	if r.Damaged == 0 {
		fmt.Println("VERDICT: every image in this library decodes completely. Nothing to do.")
		return
	}
	fmt.Printf("VERDICT: %d of %d file(s) are not healthy.\n", r.Damaged, r.TotalFiles)
	fmt.Println("Move them aside with:")
	fmt.Printf("  mediarescue quarantine %s --quarantine <qdir>%s --apply\n", r.Root, recursiveFlagText(r.Recursive))
	fmt.Println("MediaRescue does not repair files; that is PhotoRevive's job.")
}

func recursiveFlagText(recursive bool) string {
	if recursive {
		return " --recursive"
	}
	return ""
}

// ---------------------------------------------------------------------
// quarantine
// ---------------------------------------------------------------------

// MoveResult records what happened (or would happen) to one file.
type MoveResult struct {
	Path   string `json:"path"`
	Rel    string `json:"rel_path"`
	Class  string `json:"class"`
	Dest   string `json:"dest,omitempty"`
	Action string `json:"action"` // WOULD-MOVE, MOVED, KEEP, FAILED
	Size   int64  `json:"size_bytes"`
	Detail string `json:"detail,omitempty"`
}

// moveFile relocates src to dst without ever destroying data: it refuses
// to overwrite an existing destination, prefers an atomic rename, and
// falls back to copy-then-remove only after the copy is complete and its
// size verified.
func moveFile(src, dst string) error {
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("destination already exists, refusing to overwrite: %s", dst)
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return fmt.Errorf("cannot create quarantine subdirectory: %w", err)
	}
	if err := os.Rename(src, dst); err == nil {
		return nil
	}

	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	fi, err := in.Stat()
	if err != nil {
		return err
	}
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
	if err != nil {
		return err
	}
	copied, copyErr := io.Copy(out, in)
	if copyErr == nil {
		copyErr = out.Sync()
	}
	if closeErr := out.Close(); copyErr == nil {
		copyErr = closeErr
	}
	if copyErr != nil {
		os.Remove(dst)
		return copyErr
	}
	if copied != fi.Size() {
		os.Remove(dst)
		return fmt.Errorf("short copy: %d of %d bytes", copied, fi.Size())
	}
	return os.Remove(src)
}

// QuarantineReport is the JSON shape emitted by "quarantine --json".
type QuarantineReport struct {
	Tool       string         `json:"tool"`
	Command    string         `json:"command"`
	Root       string         `json:"root"`
	Quarantine string         `json:"quarantine"`
	Recursive  bool           `json:"recursive"`
	Apply      bool           `json:"apply"`
	Classes    []string       `json:"classes"`
	Moved      int            `json:"moved"`
	Kept       int            `json:"kept"`
	Failed     int            `json:"failed"`
	MovedBytes int64          `json:"moved_bytes"`
	Summary    []ClassSummary `json:"summary"`
	Results    []MoveResult   `json:"results"`
}

func runQuarantine(args []string) {
	fset := flag.NewFlagSet("quarantine", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	qdir := fset.String("quarantine", "", "directory to move damaged files into (required)")
	classes := fset.String("classes", "truncated,corrupt,empty", "comma-separated verdicts to move")
	recursive := fset.Bool("recursive", false, "descend into subdirectories")
	apply := fset.Bool("apply", false, "actually move files (default is a dry run)")
	jsonOut := fset.Bool("json", false, "emit machine-readable JSON")
	valueFlags := map[string]bool{"quarantine": true, "classes": true}
	if err := fset.Parse(reorderFlags(args, valueFlags)); err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue quarantine: %v\n\n", err)
		fmt.Fprint(os.Stderr, quarantineUsage)
		os.Exit(2)
	}
	rest := fset.Args()
	if len(rest) != 1 || *qdir == "" {
		fmt.Fprint(os.Stderr, quarantineUsage)
		os.Exit(1)
	}
	root := rest[0]

	selected := map[string]bool{}
	var selectedNames []string
	for _, raw := range strings.Split(*classes, ",") {
		name := strings.ToLower(strings.TrimSpace(raw))
		if name == "" {
			continue
		}
		canon, ok := selectableClasses[name]
		if !ok {
			fmt.Fprintf(os.Stderr, "mediarescue quarantine: unknown class %q\n", raw)
			fmt.Fprintln(os.Stderr, "valid classes: truncated, corrupt, mislabelled, empty, unreadable")
			os.Exit(2)
		}
		if !selected[canon] {
			selected[canon] = true
			selectedNames = append(selectedNames, canon)
		}
	}
	if len(selected) == 0 {
		fmt.Fprintln(os.Stderr, "mediarescue quarantine: --classes selected nothing")
		os.Exit(2)
	}

	info, err := os.Stat(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue quarantine: cannot read %s: %v\n", root, err)
		os.Exit(2)
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "mediarescue quarantine: %s is not a directory\n", root)
		os.Exit(2)
	}
	if sameDir(root, *qdir) {
		fmt.Fprintln(os.Stderr, "mediarescue quarantine: --quarantine must not be the library directory itself")
		os.Exit(2)
	}

	report := QuarantineReport{
		Tool:       "mediarescue",
		Command:    "quarantine",
		Root:       filepath.ToSlash(root),
		Quarantine: filepath.ToSlash(*qdir),
		Recursive:  *recursive,
		Apply:      *apply,
		Classes:    selectedNames,
	}

	var seen []FileResult
	walkErr := walkImages(root, *recursive, []string{*qdir}, func(path, rel string) error {
		res := classify(path, rel)
		seen = append(seen, res)
		mr := MoveResult{Path: filepath.ToSlash(path), Rel: res.Rel, Class: res.Class, Size: res.Size}
		if !selected[res.Class] {
			mr.Action = "KEEP"
			mr.Detail = "not in --classes; left exactly where it is"
			report.Kept++
			report.Results = append(report.Results, mr)
			return nil
		}
		dest := filepath.Join(*qdir, rel)
		mr.Dest = filepath.ToSlash(dest)
		if !*apply {
			mr.Action = "WOULD-MOVE"
			report.Moved++
			report.MovedBytes += res.Size
			report.Results = append(report.Results, mr)
			return nil
		}
		if err := moveFile(path, dest); err != nil {
			mr.Action = "FAILED"
			mr.Detail = err.Error()
			report.Failed++
			report.Results = append(report.Results, mr)
			return nil
		}
		mr.Action = "MOVED"
		report.Moved++
		report.MovedBytes += res.Size
		report.Results = append(report.Results, mr)
		return nil
	})
	if walkErr != nil {
		fmt.Fprintf(os.Stderr, "mediarescue quarantine: %v\n", walkErr)
		os.Exit(2)
	}
	report.Summary = summarize(seen)

	if *jsonOut {
		emitJSON(report)
		if report.Failed > 0 {
			os.Exit(1)
		}
		return
	}
	printQuarantineReport(report)
	if report.Failed > 0 {
		os.Exit(1)
	}
}

func printQuarantineReport(r QuarantineReport) {
	mode := "DRY RUN"
	if r.Apply {
		mode = "APPLY"
	}
	fmt.Println("MediaRescue - quarantine damaged files")
	fmt.Println(strings.Repeat("=", 60))
	fmt.Printf("Library:    %s\n", r.Root)
	fmt.Printf("Quarantine: %s\n", r.Quarantine)
	fmt.Printf("Classes:    %s\n", strings.Join(r.Classes, ", "))
	fmt.Printf("Mode:       %s\n", mode)
	if !r.Apply {
		fmt.Println("(nothing is moved; pass --apply to actually move the files)")
	}
	fmt.Println()

	if len(r.Results) == 0 {
		fmt.Println("No .png/.jpg/.jpeg/.gif files found here.")
		return
	}
	for _, m := range r.Results {
		if m.Dest != "" {
			fmt.Printf("  %-11s %-12s %-30s -> %s\n", m.Action, m.Class, m.Rel, m.Dest)
		} else {
			fmt.Printf("  %-11s %-12s %-30s\n", m.Action, m.Class, m.Rel)
		}
		if m.Action == "FAILED" && m.Detail != "" {
			fmt.Printf("              %s\n", m.Detail)
		}
	}

	fmt.Println()
	fmt.Println(strings.Repeat("-", 60))
	fmt.Println("SUMMARY (all files examined)")
	for _, s := range r.Summary {
		fmt.Printf("  %-12s %4d file(s)  %10s\n", s.Class, s.Count, s.BytesHuman)
	}
	fmt.Println()
	verb := "would move"
	if r.Apply {
		verb = "moved"
	}
	fmt.Printf("  %s: %d file(s) (%s)\n", verb, r.Moved, humanBytes(r.MovedBytes))
	fmt.Printf("  left in place: %d file(s)\n", r.Kept)
	if r.Failed > 0 {
		fmt.Printf("  FAILED: %d file(s)\n", r.Failed)
	}
	fmt.Println()
	fmt.Println("Files are MOVED, never deleted: everything above is still on disk.")
}

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

func runVerify(args []string) {
	fset := flag.NewFlagSet("verify", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	jsonOut := fset.Bool("json", false, "emit machine-readable JSON")
	if err := fset.Parse(reorderFlags(args, map[string]bool{})); err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue verify: %v\n\n", err)
		fmt.Fprint(os.Stderr, verifyUsage)
		os.Exit(2)
	}
	rest := fset.Args()
	if len(rest) != 1 {
		fmt.Fprint(os.Stderr, verifyUsage)
		os.Exit(1)
	}
	path := rest[0]

	if info, err := os.Stat(path); err == nil && info.IsDir() {
		fmt.Fprintf(os.Stderr, "mediarescue verify: %s is a directory (use \"mediarescue scan\" for directories)\n", path)
		os.Exit(2)
	}
	if _, err := os.Lstat(path); err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue verify: cannot read %s: %v\n", path, err)
		os.Exit(2)
	}

	res := classify(path, filepath.Base(path))
	if *jsonOut {
		emitJSON(struct {
			Tool    string     `json:"tool"`
			Command string     `json:"command"`
			Healthy bool       `json:"healthy"`
			File    FileResult `json:"file"`
		}{"mediarescue", "verify", res.Class == ClassHealthy, res})
	} else {
		fmt.Println("MediaRescue - single file verify")
		fmt.Println(strings.Repeat("=", 60))
		fmt.Printf("File:    %s\n", res.Path)
		fmt.Printf("Size:    %s (%d bytes)\n", res.SizeHuman, res.Size)
		fmt.Printf("Ext:     %s (claims %s)\n", res.Ext, orNone(res.ExtFormat))
		fmt.Printf("Magic:   %s\n", orNone(res.MagicFormat))
		if res.Width > 0 {
			fmt.Printf("Decoded: %s %dx%d\n", res.Format, res.Width, res.Height)
		} else {
			fmt.Println("Decoded: no")
		}
		fmt.Printf("VERDICT: %s\n", res.Class)
		if res.Detail != "" {
			fmt.Printf("         %s\n", res.Detail)
		}
	}
	if res.Class != ClassHealthy {
		os.Exit(1)
	}
}

func orNone(s string) string {
	if s == "" {
		return "unrecognised"
	}
	return s
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(os.Stderr, "mediarescue: error encoding json: %v\n", err)
		os.Exit(2)
	}
}

// ---------------------------------------------------------------------
// usage / main
// ---------------------------------------------------------------------

const topUsage = `MediaRescue - one-command triage for a photo library

Every image is fully decoded, not just sniffed, and gets one verdict:
  HEALTHY      decodes completely
  TRUNCATED    valid header, image data ends early
  CORRUPT      damaged or structurally invalid image data
  MISLABELLED  real format does not match the file extension
  EMPTY        zero bytes
  UNREADABLE   permission or I/O error

Usage:
  mediarescue scan <dir> [--recursive] [--json]
  mediarescue quarantine <dir> --quarantine <qdir> [--classes truncated,corrupt,empty] [--recursive] [--apply]
  mediarescue verify <file> [--json]
  mediarescue help

MediaRescue never repairs and never deletes: damaged files are MOVED to a
quarantine directory, and only when you pass --apply.

Run "mediarescue <command> -h" for details on a specific command.
`

const scanUsage = `Usage: mediarescue scan <dir> [--recursive] [--json]

Fully decodes every .png/.jpg/.jpeg/.gif file in <dir> and prints one
verdict per file plus a per-class summary with counts and bytes. Healthy
files report their real format and pixel dimensions. Read-only: no file
is created, modified, moved or deleted.

Flags:
  --recursive   also triage files in subdirectories
  --json        emit a machine-readable JSON report instead of text
`

const quarantineUsage = `Usage: mediarescue quarantine <dir> --quarantine <qdir> [--classes truncated,corrupt,empty] [--recursive] [--apply]

Triages <dir> exactly as "scan" does, then moves the files whose verdict
is listed in --classes into <qdir>, preserving each file's path relative
to <dir>. HEALTHY files are never moved.

Without --apply this is a dry run: the same decoding runs and an accurate
WOULD-MOVE preview is printed, but nothing on disk is touched.

Files are moved, never deleted. An existing destination is never
overwritten; such a file is reported as FAILED and left alone.

Flags:
  --quarantine <qdir>   directory to move damaged files into (required)
  --classes <list>      comma-separated verdicts to move
                        (truncated, corrupt, mislabelled, empty, unreadable)
                        default: truncated,corrupt,empty
  --recursive           also triage files in subdirectories
  --apply               actually move the files (default is a dry run)
  --json                emit a machine-readable JSON report instead of text
`

const verifyUsage = `Usage: mediarescue verify <file> [--json]

Fully decodes a single image file and prints its verdict, real format,
magic-byte identity and pixel dimensions. Exits 0 when the verdict is
HEALTHY and 1 otherwise, so it can gate a shell script. Read-only.

Flags:
  --json    emit a machine-readable JSON report instead of text
`

func usage() {
	fmt.Fprint(os.Stderr, topUsage)
}

func main() {
	if len(os.Args) < 2 {
		// 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)
	}

	cmd := os.Args[1]
	switch cmd {
	case "-h", "--help", "help":
		fmt.Fprint(os.Stdout, topUsage)
		return
	case "scan":
		if wantsHelp(os.Args[2:]) {
			fmt.Fprint(os.Stdout, scanUsage)
			return
		}
		runScan(os.Args[2:])
	case "quarantine":
		if wantsHelp(os.Args[2:]) {
			fmt.Fprint(os.Stdout, quarantineUsage)
			return
		}
		runQuarantine(os.Args[2:])
	case "verify":
		if wantsHelp(os.Args[2:]) {
			fmt.Fprint(os.Stdout, verifyUsage)
			return
		}
		runVerify(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "mediarescue: unknown command %q\n\n", cmd)
		usage()
		os.Exit(1)
	}
}

// wantsHelp reports whether the user asked for a subcommand's help.
func wantsHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}
