// Command imagedeck catalogues a whole library of ISO 9660 disc images.
//
// Where a single-image tool answers "what is inside this ISO?", imagedeck
// answers the library-scale questions a team share raises: which of these
// dozens of files are byte-identical duplicates, which have changed since the
// last scan, which are unbootable, and is this new download something we
// already have. It does that by recording, per image, the size, mtime,
// SHA-256, ISO 9660 volume identifier and El Torito boot record into a
// persistent JSON catalog.
//
// The ISO 9660 Primary Volume Descriptor (sector 16) and the El Torito boot
// record (sector 17) plus its boot catalog are decoded directly from the raw
// bytes with os.File.ReadAt and encoding/binary; nothing is mounted and no
// third-party code is involved. Disc images are only ever opened read-only.
package main

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

const (
	// sectorSize is the ISO 9660 logical sector size. Volume descriptors always
	// live on 2048-byte sector boundaries even if the logical block size in the
	// PVD says otherwise.
	sectorSize = 2048
	// pvdSector is the first volume descriptor slot: byte offset 32768.
	pvdSector = 16
	// bootRecordSector is where El Torito puts its Boot Record Volume
	// Descriptor: byte offset 34816.
	bootRecordSector = 17
	// maxDescriptors bounds the volume descriptor set scan.
	maxDescriptors = 64
	// elToritoID is the boot system identifier of an El Torito boot record.
	elToritoID = "EL TORITO SPECIFICATION"
	// hashChunk is the streaming buffer size used when hashing an image.
	hashChunk = 1 << 20
	// catalogVersion is the on-disc format version of the catalog file.
	catalogVersion = 1
	// shortHashLen is how much of a SHA-256 the text tables show.
	shortHashLen = 16

	// Exit codes. 1 is a hard error, 2 means "diff found differences".
	exitError   = 1
	exitChanged = 2
)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Raw ISO 9660 / El Torito reading (strictly read-only)
// ---------------------------------------------------------------------------

// isoFile is an open disc image. It is opened with os.Open and never written.
type isoFile struct {
	f    *os.File
	name string
	size int64
}

// readAt reads exactly n bytes at off, refusing reads that run past the end of
// the file. A truncated image therefore produces a clear error instead of a
// panic or a short buffer.
func (im *isoFile) readAt(off int64, n int) ([]byte, error) {
	if off < 0 || n < 0 {
		return nil, fmt.Errorf("invalid read of %d bytes at offset %d", n, off)
	}
	if off+int64(n) > im.size {
		return nil, fmt.Errorf("read of %d bytes at offset %d runs past end of image (%s) - image is truncated or corrupt",
			n, off, humanBytes(im.size))
	}
	buf := make([]byte, n)
	if _, err := io.ReadFull(io.NewSectionReader(im.f, off, int64(n)), buf); err != nil {
		return nil, fmt.Errorf("reading %d bytes at offset %d: %w", n, off, err)
	}
	return buf, nil
}

// le32 reads the little-endian half of an ISO 9660 both-endian 32-bit field.
func le32(b []byte, off int) uint32 { return binary.LittleEndian.Uint32(b[off : off+4]) }

// le16 reads the little-endian half of a both-endian 16-bit field.
func le16(b []byte, off int) uint16 { return binary.LittleEndian.Uint16(b[off : off+2]) }

// strField trims the space/NUL padding ISO 9660 uses for a- and d-strings.
func strField(b []byte, off, n int) string {
	if off+n > len(b) {
		return ""
	}
	return strings.TrimRight(string(b[off:off+n]), " \x00")
}

// volumeInfo is what the Primary Volume Descriptor contributes to a catalog
// entry.
type volumeInfo struct {
	volumeID  string
	systemID  string
	spaceSize uint32
	blockSize int64
}

// readPVD validates the CD001 magic at sector 16 and decodes the first Primary
// Volume Descriptor in the volume descriptor set.
func (im *isoFile) readPVD() (volumeInfo, error) {
	var v volumeInfo
	if im.size < int64(pvdSector*sectorSize+sectorSize) {
		return v, fmt.Errorf("not an ISO 9660 image: file is only %s, too small to hold a volume descriptor at sector 16 (offset 32768)",
			humanBytes(im.size))
	}
	for i := 0; i < maxDescriptors; i++ {
		off := int64(pvdSector+i) * sectorSize
		if off+sectorSize > im.size {
			break
		}
		d, err := im.readAt(off, sectorSize)
		if err != nil {
			return v, err
		}
		if string(d[1:6]) != "CD001" {
			if i == 0 {
				return v, errors.New("not an ISO 9660 image (missing CD001 magic at sector 16)")
			}
			break
		}
		switch d[0] {
		case 1: // Primary Volume Descriptor
			v.systemID = strField(d, 8, 32)
			v.volumeID = strField(d, 40, 32)
			v.spaceSize = le32(d, 80)
			v.blockSize = int64(le16(d, 128))
			if v.blockSize <= 0 || v.blockSize%512 != 0 || v.blockSize > 65536 {
				return v, fmt.Errorf("implausible logical block size %d in Primary Volume Descriptor", v.blockSize)
			}
			return v, nil
		case 255: // Volume Descriptor Set Terminator
			return v, errors.New("ISO 9660 volume descriptor set contains no Primary Volume Descriptor")
		}
	}
	return v, errors.New("ISO 9660 volume descriptor set contains no Primary Volume Descriptor")
}

// platformName maps an El Torito platform id to a human name.
func platformName(id byte) string {
	switch id {
	case 0x00:
		return "BIOS"
	case 0x01:
		return "PowerPC"
	case 0x02:
		return "Mac"
	case 0xEF:
		return "UEFI"
	}
	return fmt.Sprintf("unknown(0x%02X)", id)
}

// mediaTypeName maps the low nibble of an El Torito boot entry media type.
func mediaTypeName(b byte) string {
	switch b & 0x0F {
	case 0:
		return "no emulation"
	case 1:
		return "1.2M floppy"
	case 2:
		return "1.44M floppy"
	case 3:
		return "2.88M floppy"
	case 4:
		return "hard disk"
	}
	return fmt.Sprintf("unknown(0x%02X)", b&0x0F)
}

// bootInfo is the El Torito result for one image.
type bootInfo struct {
	bootable  bool
	platform  string
	note      string
	catalogAt uint32
	entries   []BootEntry
}

// readElTorito looks for the Boot Record Volume Descriptor at sector 17 and,
// if present, walks the boot catalog it points at.
//
// A missing or unreadable boot record is not an error: an image without one is
// simply not bootable. Only a genuinely broken PVD makes a file unusable.
func (im *isoFile) readElTorito() bootInfo {
	bi := bootInfo{platform: "none"}
	off := int64(bootRecordSector) * sectorSize
	if off+sectorSize > im.size {
		return bi
	}
	d, err := im.readAt(off, sectorSize)
	if err != nil {
		return bi
	}
	if d[0] != 0 || string(d[1:6]) != "CD001" || strField(d, 7, 32) != elToritoID {
		return bi
	}
	// Offset 0x47 holds the absolute sector number of the boot catalog.
	lba := le32(d, 0x47)
	bi.catalogAt = lba
	cOff := int64(lba) * sectorSize
	if cOff < 0 || cOff+sectorSize > im.size {
		bi.note = fmt.Sprintf("El Torito boot record points at sector %d, which is past the end of the file", lba)
		return bi
	}
	cat, err := im.readAt(cOff, sectorSize)
	if err != nil {
		bi.note = "El Torito boot catalog could not be read: " + err.Error()
		return bi
	}
	bi.entries, bi.note = parseBootCatalog(cat)
	bi.platform = summarizePlatforms(bi.entries)
	for _, e := range bi.entries {
		if e.Bootable {
			bi.bootable = true
		}
	}
	return bi
}

// parseBootCatalog decodes the 32-byte records of an El Torito boot catalog:
// a validation entry, one default entry, then any number of section headers
// each followed by its section entries.
func parseBootCatalog(cat []byte) ([]BootEntry, string) {
	if len(cat) < 64 {
		return nil, "El Torito boot catalog is too short"
	}
	if cat[0] != 0x01 || cat[30] != 0x55 || cat[31] != 0xAA {
		return nil, "El Torito boot catalog has no valid validation entry"
	}
	plat := cat[1]
	var out []BootEntry
	out = append(out, decodeBootEntry(cat[32:64], plat, "default"))

	pos := 64
	remaining := 0
	final := false
	for pos+32 <= len(cat) {
		r := cat[pos : pos+32]
		if remaining > 0 {
			out = append(out, decodeBootEntry(r, plat, "section"))
			remaining--
			pos += 32
			if remaining == 0 && final {
				break
			}
			continue
		}
		if r[0] != 0x90 && r[0] != 0x91 {
			break // 0x00 / 0xFF / anything else terminates the catalog
		}
		final = r[0] == 0x91
		plat = r[1]
		remaining = int(le16(r, 2))
		pos += 32
		if remaining == 0 && final {
			break
		}
	}
	return out, ""
}

func decodeBootEntry(r []byte, plat byte, kind string) BootEntry {
	return BootEntry{
		Kind:        kind,
		Platform:    platformName(plat),
		Bootable:    r[0] == 0x88,
		MediaType:   mediaTypeName(r[1]),
		LoadSegment: le16(r, 2),
		SystemType:  r[4],
		SectorCount: le16(r, 6),
		LoadRBA:     le32(r, 8),
	}
}

// summarizePlatforms reduces the bootable entries to one of none / BIOS /
// UEFI / both, which is the answer the library-scale questions need.
func summarizePlatforms(entries []BootEntry) string {
	seen := map[string]bool{}
	var order []string
	for _, e := range entries {
		if !e.Bootable || seen[e.Platform] {
			continue
		}
		seen[e.Platform] = true
		order = append(order, e.Platform)
	}
	switch {
	case len(order) == 0:
		return "none"
	case len(order) == 2 && seen["BIOS"] && seen["UEFI"]:
		return "both"
	default:
		sort.Strings(order)
		return strings.Join(order, "+")
	}
}

// inspectISO opens an image read-only and decodes everything the catalog
// records about it.
func inspectISO(path string, info fs.FileInfo) (CatalogEntry, error) {
	f, err := os.Open(path)
	if err != nil {
		return CatalogEntry{}, err
	}
	defer f.Close()

	im := &isoFile{f: f, name: path, size: info.Size()}
	vol, err := im.readPVD()
	if err != nil {
		return CatalogEntry{}, err
	}
	bi := im.readElTorito()

	sum, err := hashFile(f)
	if err != nil {
		return CatalogEntry{}, err
	}
	declared := int64(vol.spaceSize) * vol.blockSize
	return CatalogEntry{
		Path:          path,
		Size:          info.Size(),
		Modified:      info.ModTime().UTC().Format(time.RFC3339),
		SHA256:        sum,
		VolumeID:      vol.volumeID,
		SystemID:      vol.systemID,
		DeclaredBytes: declared,
		Truncated:     declared > info.Size(),
		Bootable:      bi.bootable,
		BootPlatform:  bi.platform,
		BootCatalog:   bi.catalogAt,
		BootNote:      bi.note,
		BootEntries:   bi.entries,
	}, nil
}

// hashFile streams the whole file through SHA-256 from offset 0.
func hashFile(f *os.File) (string, error) {
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		return "", err
	}
	h := sha256.New()
	if _, err := io.CopyBuffer(h, f, make([]byte, hashChunk)); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// hashPath opens a file read-only and returns its SHA-256.
func hashPath(path string) (string, int64, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	st, err := f.Stat()
	if err != nil {
		return "", 0, err
	}
	if st.IsDir() {
		return "", 0, fmt.Errorf("%s: is a directory, not a file", path)
	}
	sum, err := hashFile(f)
	return sum, st.Size(), err
}

// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------

// BootEntry is one decoded El Torito boot catalog entry.
type BootEntry struct {
	Kind        string `json:"kind"`
	Platform    string `json:"platform"`
	Bootable    bool   `json:"bootable"`
	MediaType   string `json:"media_type"`
	LoadSegment uint16 `json:"load_segment"`
	SystemType  byte   `json:"system_type"`
	SectorCount uint16 `json:"sector_count"`
	LoadRBA     uint32 `json:"load_rba"`
}

// CatalogEntry is one indexed disc image.
type CatalogEntry struct {
	Path          string      `json:"path"`
	Size          int64       `json:"size"`
	SizeHuman     string      `json:"size_human,omitempty"`
	Modified      string      `json:"modified"`
	SHA256        string      `json:"sha256"`
	VolumeID      string      `json:"volume_id"`
	SystemID      string      `json:"system_id"`
	DeclaredBytes int64       `json:"declared_bytes"`
	Truncated     bool        `json:"truncated"`
	Bootable      bool        `json:"bootable"`
	BootPlatform  string      `json:"boot_platform"`
	BootCatalog   uint32      `json:"boot_catalog_lba,omitempty"`
	BootNote      string      `json:"boot_note,omitempty"`
	BootEntries   []BootEntry `json:"boot_entries,omitempty"`
}

// CatalogFailure is a file that looked like an image but could not be parsed.
// Failures never abort a scan; they are recorded alongside the good entries.
type CatalogFailure struct {
	Path  string `json:"path"`
	Size  int64  `json:"size"`
	Error string `json:"error"`
}

// Catalog is the persistent library index. It is the only file imagedeck
// writes.
type Catalog struct {
	Tool      string           `json:"tool"`
	Version   int              `json:"catalog_version"`
	Root      string           `json:"root"`
	Recursive bool             `json:"recursive"`
	Indexed   string           `json:"indexed"`
	Images    int              `json:"images"`
	Entries   []CatalogEntry   `json:"entries"`
	Failures  []CatalogFailure `json:"failures,omitempty"`
}

func loadCatalog(path string) (*Catalog, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("catalog not found: %s (run \"imagedeck index <dir> --catalog %s\" first)", path, path)
		}
		return nil, err
	}
	var c Catalog
	if err := json.Unmarshal(data, &c); err != nil {
		return nil, fmt.Errorf("catalog %s is not valid JSON: %v", path, err)
	}
	if c.Tool != "imagedeck" {
		return nil, fmt.Errorf("catalog %s is not an imagedeck catalog (missing \"tool\": \"imagedeck\")", path)
	}
	return &c, nil
}

// saveCatalog writes the catalog atomically: a sibling temp file, then rename.
func saveCatalog(path string, c *Catalog) error {
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".imagedeck-*.tmp")
	if err != nil {
		return fmt.Errorf("cannot write catalog in %s: %w", dir, err)
	}
	name := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		os.Remove(name)
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(name)
		return err
	}
	if err := os.Rename(name, path); err != nil {
		os.Remove(name)
		return err
	}
	return nil
}

// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------

func isISOName(name string) bool {
	return strings.EqualFold(filepath.Ext(name), ".iso")
}

// collectISOs lists candidate .iso files under dir, sorted by path.
func collectISOs(dir string, recursive bool) ([]string, error) {
	st, err := os.Stat(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("no such directory: %s", dir)
		}
		return nil, err
	}
	if !st.IsDir() {
		return nil, fmt.Errorf("%s is not a directory", dir)
	}
	var out []string
	if recursive {
		err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
			if err != nil {
				fmt.Fprintf(os.Stderr, "imagedeck: warning: %v\n", err)
				if d != nil && d.IsDir() {
					return fs.SkipDir
				}
				return nil
			}
			if d.IsDir() || !d.Type().IsRegular() || !isISOName(d.Name()) {
				return nil
			}
			out = append(out, p)
			return nil
		})
		if err != nil {
			return nil, err
		}
	} else {
		ents, err := os.ReadDir(dir)
		if err != nil {
			return nil, err
		}
		for _, e := range ents {
			if e.IsDir() || !e.Type().IsRegular() || !isISOName(e.Name()) {
				continue
			}
			out = append(out, filepath.Join(dir, e.Name()))
		}
	}
	sort.Strings(out)
	return out, nil
}

// scanDir inspects every .iso under dir. A file that cannot be parsed is
// recorded as a failure and the scan continues with the next file.
func scanDir(dir string, recursive bool) ([]CatalogEntry, []CatalogFailure, error) {
	paths, err := collectISOs(dir, recursive)
	if err != nil {
		return nil, nil, err
	}
	var entries []CatalogEntry
	var failures []CatalogFailure
	for _, p := range paths {
		info, err := os.Stat(p)
		if err != nil {
			failures = append(failures, CatalogFailure{Path: p, Error: err.Error()})
			continue
		}
		e, err := inspectISO(p, info)
		if err != nil {
			failures = append(failures, CatalogFailure{Path: p, Size: info.Size(), Error: err.Error()})
			continue
		}
		e.SizeHuman = humanBytes(e.Size)
		entries = append(entries, e)
	}
	return entries, failures, nil
}

// absDir normalizes a directory argument for comparison and storage.
func absDir(dir string) string {
	a, err := filepath.Abs(dir)
	if err != nil {
		return filepath.Clean(dir)
	}
	return a
}

// ---------------------------------------------------------------------------
// Text rendering
// ---------------------------------------------------------------------------

func shortHash(s string) string {
	if len(s) > shortHashLen {
		return s[:shortHashLen]
	}
	return s
}

func volDisplay(s string) string {
	if s == "" {
		return "(none)"
	}
	return s
}

func bootDisplay(e CatalogEntry) string {
	if !e.Bootable {
		return "no"
	}
	return "yes/" + e.BootPlatform
}

// displayPath shortens a path for the text tables by making it relative to the
// scanned root. JSON output always keeps the full absolute path.
func displayPath(root, p string) string {
	if root == "" {
		return p
	}
	prefix := root + string(filepath.Separator)
	if strings.HasPrefix(p, prefix) {
		return p[len(prefix):]
	}
	return p
}

func printEntryTable(root string, entries []CatalogEntry) {
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "VOLUME ID\tSIZE\tBYTES\tBOOT\tSHA-256\tPATH")
	for _, e := range entries {
		fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\n",
			volDisplay(e.VolumeID), humanBytes(e.Size), e.Size, bootDisplay(e), shortHash(e.SHA256), displayPath(root, e.Path))
	}
	w.Flush()
}

// printTruncated flags images whose Primary Volume Descriptor declares more
// data than the file actually holds - a download that stopped early.
func printTruncated(root string, entries []CatalogEntry) {
	var bad []CatalogEntry
	for _, e := range entries {
		if e.Truncated {
			bad = append(bad, e)
		}
	}
	if len(bad) == 0 {
		return
	}
	fmt.Printf("\nTRUNCATED - %s where the volume declares more data than the file holds:\n",
		plural(len(bad), "image", "images"))
	for _, e := range bad {
		fmt.Printf("  %s: volume declares %s, file is only %s\n",
			displayPath(root, e.Path), humanBytes(e.DeclaredBytes), humanBytes(e.Size))
	}
}

func printFailures(root string, failures []CatalogFailure) {
	if len(failures) == 0 {
		return
	}
	fmt.Printf("\nSKIPPED (%s could not be parsed, scan continued):\n", plural(len(failures), "file", "files"))
	for _, f := range failures {
		fmt.Printf("  %s (%s): %s\n", displayPath(root, f.Path), humanBytes(f.Size), f.Error)
	}
}

func plural(n int, one, many string) string {
	if n == 1 {
		return fmt.Sprintf("%d %s", n, one)
	}
	return fmt.Sprintf("%d %s", n, many)
}

func writeJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fs.Usage = func() {}
	return fs
}

var valueFlags = map[string]bool{"catalog": true, "sha256": true, "file": true}

// parseArgs reorders flags after positionals, then parses. flag.ErrHelp is
// reported as a clean help request.
func parseArgs(fs *flag.FlagSet, args []string) error {
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageTo(os.Stdout)
			os.Exit(0)
		}
		return err
	}
	return nil
}

func requireCatalog(p string) error {
	if p == "" {
		return errors.New("--catalog <catalog.json> is required")
	}
	return nil
}

type indexJSON struct {
	Command   string           `json:"command"`
	Catalog   string           `json:"catalog"`
	Root      string           `json:"root"`
	Recursive bool             `json:"recursive"`
	Indexed   string           `json:"indexed"`
	Images    int              `json:"images"`
	Skipped   int              `json:"skipped"`
	Entries   []CatalogEntry   `json:"entries"`
	Failures  []CatalogFailure `json:"failures"`
}

func cmdIndex(args []string) error {
	fs := newFlagSet("index")
	catPath := fs.String("catalog", "", "catalog file to write")
	recursive := fs.Bool("recursive", false, "descend into subdirectories")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("index takes exactly one argument: <dir> (plus --catalog <catalog.json>)")
	}
	if err := requireCatalog(*catPath); err != nil {
		return err
	}
	root := absDir(rest[0])
	entries, failures, err := scanDir(root, *recursive)
	if err != nil {
		return err
	}
	now := time.Now().UTC().Format(time.RFC3339)
	c := &Catalog{
		Tool:      "imagedeck",
		Version:   catalogVersion,
		Root:      root,
		Recursive: *recursive,
		Indexed:   now,
		Images:    len(entries),
		Entries:   entries,
		Failures:  failures,
	}
	if c.Entries == nil {
		c.Entries = []CatalogEntry{}
	}
	if err := saveCatalog(*catPath, c); err != nil {
		return err
	}

	if *asJSON {
		j := indexJSON{
			Command:   "index",
			Catalog:   *catPath,
			Root:      root,
			Recursive: *recursive,
			Indexed:   now,
			Images:    len(entries),
			Skipped:   len(failures),
			Entries:   c.Entries,
			Failures:  failures,
		}
		if j.Failures == nil {
			j.Failures = []CatalogFailure{}
		}
		return writeJSON(j)
	}

	scope := "non-recursive"
	if *recursive {
		scope = "recursive"
	}
	fmt.Printf("Indexed %s (%s)\n\n", root, scope)
	if len(entries) == 0 {
		fmt.Printf("No .iso files parsed successfully in %s\n", root)
	} else {
		printEntryTable(root, entries)
		printTruncated(root, entries)
	}
	printFailures(root, failures)
	fmt.Printf("\n%s indexed, %d skipped -> catalog %s\n", plural(len(entries), "image", "images"), len(failures), *catPath)
	return nil
}

type listJSON struct {
	Command string         `json:"command"`
	Catalog string         `json:"catalog"`
	Root    string         `json:"root"`
	Indexed string         `json:"indexed"`
	Filter  string         `json:"filter"`
	Images  int            `json:"images"`
	Entries []CatalogEntry `json:"entries"`
}

func cmdList(args []string) error {
	fs := newFlagSet("list")
	catPath := fs.String("catalog", "", "catalog file to read")
	bootable := fs.Bool("bootable", false, "only bootable images")
	unbootable := fs.Bool("unbootable", false, "only images with no El Torito boot record")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args); err != nil {
		return err
	}
	if len(fs.Args()) != 0 {
		return fmt.Errorf("list takes no positional arguments (got %q)", strings.Join(fs.Args(), " "))
	}
	if err := requireCatalog(*catPath); err != nil {
		return err
	}
	if *bootable && *unbootable {
		return errors.New("--bootable and --unbootable are mutually exclusive")
	}
	c, err := loadCatalog(*catPath)
	if err != nil {
		return err
	}
	filter := "all"
	out := make([]CatalogEntry, 0, len(c.Entries))
	for _, e := range c.Entries {
		switch {
		case *bootable && !e.Bootable:
			continue
		case *unbootable && e.Bootable:
			continue
		}
		out = append(out, e)
	}
	if *bootable {
		filter = "bootable"
	}
	if *unbootable {
		filter = "unbootable"
	}

	if *asJSON {
		return writeJSON(listJSON{
			Command: "list", Catalog: *catPath, Root: c.Root, Indexed: c.Indexed,
			Filter: filter, Images: len(out), Entries: out,
		})
	}
	fmt.Printf("Catalog %s - %s indexed %s from %s\n\n",
		*catPath, plural(len(c.Entries), "image", "images"), c.Indexed, c.Root)
	if len(out) == 0 {
		fmt.Printf("No images match filter %q\n", filter)
		return nil
	}
	printEntryTable(c.Root, out)
	printTruncated(c.Root, out)
	fmt.Printf("\n%s shown (filter: %s)\n", plural(len(out), "image", "images"), filter)
	return nil
}

type dupGroupJSON struct {
	SHA256          string   `json:"sha256"`
	Size            int64    `json:"size"`
	SizeHuman       string   `json:"size_human"`
	Copies          int      `json:"copies"`
	ReclaimableByte int64    `json:"reclaimable_bytes"`
	Paths           []string `json:"paths"`
}

type dupJSON struct {
	Command          string         `json:"command"`
	Catalog          string         `json:"catalog"`
	Images           int            `json:"images"`
	Groups           []dupGroupJSON `json:"groups"`
	GroupCount       int            `json:"group_count"`
	RedundantCopies  int            `json:"redundant_copies"`
	ReclaimableBytes int64          `json:"reclaimable_bytes"`
	ReclaimableHuman string         `json:"reclaimable_human"`
}

func cmdDuplicates(args []string) error {
	fs := newFlagSet("duplicates")
	catPath := fs.String("catalog", "", "catalog file to read")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args); err != nil {
		return err
	}
	if len(fs.Args()) != 0 {
		return fmt.Errorf("duplicates takes no positional arguments (got %q)", strings.Join(fs.Args(), " "))
	}
	if err := requireCatalog(*catPath); err != nil {
		return err
	}
	c, err := loadCatalog(*catPath)
	if err != nil {
		return err
	}

	// Group strictly by SHA-256: same content, whatever the filename, volume
	// identifier or mtime says.
	byHash := map[string][]CatalogEntry{}
	var order []string
	for _, e := range c.Entries {
		if _, ok := byHash[e.SHA256]; !ok {
			order = append(order, e.SHA256)
		}
		byHash[e.SHA256] = append(byHash[e.SHA256], e)
	}
	var groups []dupGroupJSON
	var reclaim int64
	redundant := 0
	for _, h := range order {
		g := byHash[h]
		if len(g) < 2 {
			continue
		}
		size := g[0].Size
		r := int64(len(g)-1) * size
		reclaim += r
		redundant += len(g) - 1
		paths := make([]string, 0, len(g))
		for _, e := range g {
			paths = append(paths, e.Path)
		}
		groups = append(groups, dupGroupJSON{
			SHA256: h, Size: size, SizeHuman: humanBytes(size),
			Copies: len(g), ReclaimableByte: r, Paths: paths,
		})
	}
	sort.Slice(groups, func(i, j int) bool {
		if groups[i].ReclaimableByte != groups[j].ReclaimableByte {
			return groups[i].ReclaimableByte > groups[j].ReclaimableByte
		}
		return groups[i].SHA256 < groups[j].SHA256
	})

	if *asJSON {
		if groups == nil {
			groups = []dupGroupJSON{}
		}
		return writeJSON(dupJSON{
			Command: "duplicates", Catalog: *catPath, Images: len(c.Entries),
			Groups: groups, GroupCount: len(groups), RedundantCopies: redundant,
			ReclaimableBytes: reclaim, ReclaimableHuman: humanBytes(reclaim),
		})
	}
	if len(groups) == 0 {
		fmt.Printf("No byte-identical duplicates among %s in %s\n", plural(len(c.Entries), "image", "images"), *catPath)
		return nil
	}
	fmt.Printf("Catalog %s - root %s\n\n", *catPath, c.Root)
	for i, g := range groups {
		fmt.Printf("Duplicate group %d: %d copies, %s each (%d bytes), %s reclaimable\n",
			i+1, g.Copies, g.SizeHuman, g.Size, humanBytes(g.ReclaimableByte))
		fmt.Printf("  sha256 %s\n", g.SHA256)
		for _, p := range g.Paths {
			fmt.Printf("    %s\n", displayPath(c.Root, p))
		}
		fmt.Println()
	}
	fmt.Printf("%s, %s, %s reclaimable (%d bytes) across %s\n",
		plural(len(groups), "duplicate group", "duplicate groups"),
		plural(redundant, "redundant copy", "redundant copies"),
		humanBytes(reclaim), reclaim,
		plural(len(c.Entries), "catalogued image", "catalogued images"))
	return nil
}

type diffChange struct {
	Path        string `json:"path"`
	Status      string `json:"status"`
	Size        int64  `json:"size"`
	OldSHA256   string `json:"old_sha256,omitempty"`
	NewSHA256   string `json:"new_sha256,omitempty"`
	OldSize     int64  `json:"old_size,omitempty"`
	NewSize     int64  `json:"new_size,omitempty"`
	VolumeID    string `json:"volume_id,omitempty"`
	OldVolumeID string `json:"old_volume_id,omitempty"`
}

type diffJSON struct {
	Command    string           `json:"command"`
	Catalog    string           `json:"catalog"`
	Dir        string           `json:"dir"`
	Recursive  bool             `json:"recursive"`
	HasChanges bool             `json:"has_changes"`
	Added      []diffChange     `json:"added"`
	Removed    []diffChange     `json:"removed"`
	Modified   []diffChange     `json:"changed"`
	Unchanged  int              `json:"unchanged"`
	Skipped    []CatalogFailure `json:"skipped"`
	ExitCode   int              `json:"exit_code"`
}

func cmdDiff(args []string) error {
	fs := newFlagSet("diff")
	catPath := fs.String("catalog", "", "catalog file to read")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("diff takes exactly one argument: <dir> (plus --catalog <catalog.json>)")
	}
	if err := requireCatalog(*catPath); err != nil {
		return err
	}
	c, err := loadCatalog(*catPath)
	if err != nil {
		return err
	}
	dir := absDir(rest[0])
	// The scan mirrors how the catalog was built, so a recursive catalog is
	// compared against a recursive scan.
	now, failures, err := scanDir(dir, c.Recursive)
	if err != nil {
		return err
	}

	old := map[string]CatalogEntry{}
	outside := 0
	prefix := dir + string(filepath.Separator)
	for _, e := range c.Entries {
		if e.Path != dir && !strings.HasPrefix(e.Path, prefix) {
			outside++
			continue
		}
		old[e.Path] = e
	}
	cur := map[string]CatalogEntry{}
	for _, e := range now {
		cur[e.Path] = e
	}

	var added, removed, modified []diffChange
	unchanged := 0
	for _, e := range now {
		o, ok := old[e.Path]
		if !ok {
			added = append(added, diffChange{Path: e.Path, Status: "ADDED", Size: e.Size, NewSHA256: e.SHA256, VolumeID: e.VolumeID})
			continue
		}
		if o.SHA256 != e.SHA256 {
			modified = append(modified, diffChange{
				Path: e.Path, Status: "CHANGED",
				OldSHA256: o.SHA256, NewSHA256: e.SHA256,
				OldSize: o.Size, NewSize: e.Size,
				OldVolumeID: o.VolumeID, VolumeID: e.VolumeID,
			})
			continue
		}
		unchanged++
	}
	for _, e := range c.Entries {
		if _, ok := old[e.Path]; !ok {
			continue
		}
		if _, ok := cur[e.Path]; !ok {
			removed = append(removed, diffChange{Path: e.Path, Status: "REMOVED", Size: e.Size, OldSHA256: e.SHA256, VolumeID: e.VolumeID})
		}
	}
	changed := len(added)+len(removed)+len(modified) > 0

	if *asJSON {
		j := diffJSON{
			Command: "diff", Catalog: *catPath, Dir: dir, Recursive: c.Recursive,
			HasChanges: changed, Added: added, Removed: removed, Modified: modified,
			Unchanged: unchanged, Skipped: failures,
		}
		if j.Added == nil {
			j.Added = []diffChange{}
		}
		if j.Removed == nil {
			j.Removed = []diffChange{}
		}
		if j.Modified == nil {
			j.Modified = []diffChange{}
		}
		if j.Skipped == nil {
			j.Skipped = []CatalogFailure{}
		}
		if changed {
			j.ExitCode = exitChanged
		}
		if err := writeJSON(j); err != nil {
			return err
		}
		if changed {
			os.Exit(exitChanged)
		}
		return nil
	}

	fmt.Printf("Comparing %s against catalog %s (indexed %s)\n\n", dir, *catPath, c.Indexed)
	if !changed {
		fmt.Printf("No changes: %s match the catalog.\n", plural(unchanged, "image", "images"))
		if outside > 0 {
			fmt.Printf("(%s in the catalog live outside %s and were not compared)\n", plural(outside, "image", "images"), dir)
		}
		printFailures(dir, failures)
		return nil
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	for _, d := range added {
		fmt.Fprintf(w, "ADDED\t%s\t%s\tsha256 %s\n", displayPath(dir, d.Path), humanBytes(d.Size), shortHash(d.NewSHA256))
	}
	for _, d := range removed {
		fmt.Fprintf(w, "REMOVED\t%s\t%s\tsha256 %s\n", displayPath(dir, d.Path), humanBytes(d.Size), shortHash(d.OldSHA256))
	}
	for _, d := range modified {
		fmt.Fprintf(w, "CHANGED\t%s\t%s -> %s\tsha256 %s -> %s\n",
			displayPath(dir, d.Path), humanBytes(d.OldSize), humanBytes(d.NewSize), shortHash(d.OldSHA256), shortHash(d.NewSHA256))
	}
	w.Flush()
	fmt.Printf("\n%d added, %d removed, %d changed, %d unchanged\n", len(added), len(removed), len(modified), unchanged)
	if outside > 0 {
		fmt.Printf("(%s in the catalog live outside %s and were not compared)\n", plural(outside, "image", "images"), dir)
	}
	printFailures(dir, failures)
	os.Exit(exitChanged)
	return nil
}

type findJSON struct {
	Command       string         `json:"command"`
	Catalog       string         `json:"catalog"`
	Query         string         `json:"query"`
	QuerySHA256   string         `json:"query_sha256"`
	CandidateFile string         `json:"candidate_file,omitempty"`
	CandidateSize int64          `json:"candidate_size,omitempty"`
	Found         bool           `json:"found"`
	Matches       []CatalogEntry `json:"matches"`
	Images        int            `json:"images_searched"`
}

func cmdFind(args []string) error {
	fs := newFlagSet("find")
	catPath := fs.String("catalog", "", "catalog file to read")
	wantHash := fs.String("sha256", "", "look up this SHA-256")
	wantFile := fs.String("file", "", "hash this file and look it up")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args); err != nil {
		return err
	}
	if len(fs.Args()) != 0 {
		return fmt.Errorf("find takes no positional arguments (got %q); use --sha256 H or --file F", strings.Join(fs.Args(), " "))
	}
	if err := requireCatalog(*catPath); err != nil {
		return err
	}
	switch {
	case *wantHash == "" && *wantFile == "":
		return errors.New("find requires either --sha256 <hash> or --file <file.iso>")
	case *wantHash != "" && *wantFile != "":
		return errors.New("--sha256 and --file are mutually exclusive")
	}
	c, err := loadCatalog(*catPath)
	if err != nil {
		return err
	}

	query, target := "sha256", strings.ToLower(strings.TrimSpace(*wantHash))
	var candSize int64
	if *wantFile != "" {
		query = "file"
		sum, size, err := hashPath(*wantFile)
		if err != nil {
			return err
		}
		target, candSize = sum, size
	} else {
		if len(target) != 64 {
			return fmt.Errorf("--sha256 expects a 64-character hex digest, got %d characters", len(target))
		}
		if _, err := hex.DecodeString(target); err != nil {
			return fmt.Errorf("--sha256 is not valid hex: %v", err)
		}
	}

	matches := make([]CatalogEntry, 0, 2)
	for _, e := range c.Entries {
		if strings.EqualFold(e.SHA256, target) {
			matches = append(matches, e)
		}
	}

	if *asJSON {
		return writeJSON(findJSON{
			Command: "find", Catalog: *catPath, Query: query, QuerySHA256: target,
			CandidateFile: *wantFile, CandidateSize: candSize,
			Found: len(matches) > 0, Matches: matches, Images: len(c.Entries),
		})
	}
	if *wantFile != "" {
		fmt.Printf("Candidate: %s (%s, %d bytes)\n", *wantFile, humanBytes(candSize), candSize)
	}
	fmt.Printf("sha256:    %s\n\n", target)
	if len(matches) == 0 {
		fmt.Printf("NOT IN LIBRARY - no catalogued image has this hash (%s searched)\n",
			plural(len(c.Entries), "image", "images"))
		return nil
	}
	fmt.Printf("ALREADY IN LIBRARY - %s:\n", plural(len(matches), "matching entry", "matching entries"))
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "  VOLUME ID\tSIZE\tBOOT\tPATH")
	for _, e := range matches {
		fmt.Fprintf(w, "  %s\t%s\t%s\t%s\n", volDisplay(e.VolumeID), humanBytes(e.Size), bootDisplay(e), e.Path)
	}
	w.Flush()
	return nil
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

func usageTo(w io.Writer) {
	fmt.Fprint(w, `imagedeck - catalog a whole library of ISO 9660 images: duplicates, drift, bootability

USAGE
  imagedeck <command> [options]

COMMANDS
  index      <dir> --catalog FILE [--recursive] [--json]  Scan every .iso into the catalog
  list       --catalog FILE [--bootable|--unbootable] [--json]  Show the catalogued library
  duplicates --catalog FILE [--json]                      Byte-identical images and wasted space
  diff       --catalog FILE <dir> [--json]                ADDED/REMOVED/CHANGED since the scan
  find       --catalog FILE (--sha256 H | --file F) [--json]  "Do we already have this?"
  help                                                    Show this help

OPTIONS
  --catalog FILE  Catalog file to read or write (required by every command)
  --recursive     index: descend into subdirectories
  --bootable      list: only images with an El Torito boot record
  --unbootable    list: only images without one
  --sha256 H      find: look up a known 64-hex-character digest
  --file F        find: hash a candidate file, then look it up
  --json          Emit machine-readable JSON
  -h, --help      Show this help

EXIT CODES
  0  success (for diff: the directory matches the catalog)
  1  error
  2  diff only: the directory differs from the catalog

NOTES
  Disc images are opened strictly read-only; the catalog file is the only thing
  imagedeck ever writes, and it is written atomically via a temp file + rename.
  A file that cannot be parsed as ISO 9660 is reported as skipped and the rest
  of the scan continues. Duplicate detection compares SHA-256 only, so images
  that merely share a size or a volume identifier are never grouped together.
  Flags may appear before or after positional arguments.

EXAMPLES
  imagedeck index /srv/isos --catalog isos.json --recursive
  imagedeck list --catalog isos.json --unbootable
  imagedeck duplicates --catalog isos.json
  imagedeck diff --catalog isos.json /srv/isos
  imagedeck find --catalog isos.json --file ~/Downloads/final_v2_REAL.iso
  imagedeck find --catalog isos.json --sha256 e3b0c44298fc1c149afbf4c8996fb924...
`)
}

func usage() { usageTo(os.Stderr) }

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// 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(exitError)
	}
	args := os.Args[2:]
	var err error
	switch os.Args[1] {
	case "-h", "--help", "help", "-help", "--h":
		usageTo(os.Stdout)
		os.Exit(0)
	case "index":
		err = cmdIndex(args)
	case "list":
		err = cmdList(args)
	case "duplicates":
		err = cmdDuplicates(args)
	case "diff":
		err = cmdDiff(args)
	case "find":
		err = cmdFind(args)
	default:
		fmt.Fprintf(os.Stderr, "imagedeck: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(exitError)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "imagedeck: %v\n", err)
		os.Exit(exitError)
	}
}
