// Command isopilot reads ISO 9660 disc images: it parses the Primary Volume
// Descriptor, walks the on-disc directory records, and extracts file data by
// seeking to the recorded extent. The ISO 9660 structures are decoded directly
// from the raw bytes with os.File.ReadAt and encoding/binary; no third-party
// or filesystem-mounting help is involved.
package main

import (
	"encoding/binary"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path"
	"strconv"
	"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
	// maxDescriptors bounds the volume descriptor set scan.
	maxDescriptors = 64
	// maxDepth bounds directory recursion so a malformed image cannot blow the
	// stack.
	maxDepth = 64
	// maxEntries bounds the total number of directory entries collected.
	maxEntries = 500000
	// maxDirBytes rejects directories that declare an implausible data length.
	maxDirBytes = 64 << 20
	// copyChunk is the streaming buffer size used by extract.
	copyChunk = 1 << 20
	// dirRecMin is the size of a directory record header before the identifier.
	dirRecMin = 33

	flagDir         = 0x02
	flagMultiExtent = 0x80
)

// ---------------------------------------------------------------------------
// 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 reading
// ---------------------------------------------------------------------------

// image is an open ISO 9660 file plus its decoded Primary Volume Descriptor.
type image struct {
	f    *os.File
	name string
	size int64

	systemID      string
	volumeID      string
	volumeSetID   string
	publisher     string
	dataPreparer  string
	applicationID string

	blockSize  int64
	spaceSize  uint32
	rootExtent uint32
	rootLength uint32

	created    time.Time
	hasCreated bool
}

// 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 *image) 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.
// Both-endian fields store little-endian first, then big-endian.
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")
}

func zoneName(quarters int8) string {
	total := int(quarters) * 15
	sign := "+"
	if total < 0 {
		sign = "-"
		total = -total
	}
	return fmt.Sprintf("UTC%s%02d:%02d", sign, total/60, total%60)
}

// parseVolDate decodes the 17-byte ASCII "dec-datetime" used by the PVD.
func parseVolDate(b []byte) (time.Time, bool) {
	if len(b) < 17 {
		return time.Time{}, false
	}
	digits := b[:16]
	unset := true
	for _, c := range digits {
		if c != '0' && c != 0 && c != ' ' {
			unset = false
			break
		}
	}
	if unset {
		return time.Time{}, false
	}
	num := func(off, n int) int {
		v, err := strconv.Atoi(strings.TrimSpace(string(digits[off : off+n])))
		if err != nil {
			return -1
		}
		return v
	}
	year, month, day := num(0, 4), num(4, 2), num(6, 2)
	hour, min, sec, hun := num(8, 2), num(10, 2), num(12, 2), num(14, 2)
	if year < 1 || month < 1 || month > 12 || day < 1 || day > 31 ||
		hour < 0 || hour > 23 || min < 0 || min > 59 || sec < 0 || sec > 60 || hun < 0 {
		return time.Time{}, false
	}
	q := int8(b[16])
	loc := time.FixedZone(zoneName(q), int(q)*15*60)
	return time.Date(year, time.Month(month), day, hour, min, sec, hun*10*1e6, loc), true
}

// parseRecDate decodes the 7-byte binary timestamp in a directory record.
func parseRecDate(b []byte) (time.Time, bool) {
	if len(b) < 7 {
		return time.Time{}, false
	}
	if b[0] == 0 && b[1] == 0 && b[2] == 0 && b[3] == 0 && b[4] == 0 && b[5] == 0 {
		return time.Time{}, false
	}
	month, day := int(b[1]), int(b[2])
	if month < 1 || month > 12 || day < 1 || day > 31 {
		return time.Time{}, false
	}
	q := int8(b[6])
	loc := time.FixedZone(zoneName(q), int(q)*15*60)
	return time.Date(1900+int(b[0]), time.Month(month), day, int(b[3]), int(b[4]), int(b[5]), 0, loc), true
}

// openImage opens the file and decodes its Primary Volume Descriptor.
func openImage(name string) (*image, error) {
	f, err := os.Open(name)
	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 ISO 9660 image", name)
	}
	im := &image{f: f, name: name, size: st.Size()}
	if err := im.readPVD(); err != nil {
		f.Close()
		return nil, err
	}
	return im, nil
}

func (im *image) Close() error { return im.f.Close() }

// readPVD validates the CD001 magic at sector 16 and decodes the first Primary
// Volume Descriptor in the volume descriptor set.
func (im *image) readPVD() error {
	if im.size < int64(pvdSector*sectorSize+sectorSize) {
		return fmt.Errorf("%s: not an ISO 9660 image: file is only %s, too small to hold a volume descriptor at sector 16 (offset 32768)",
			im.name, 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 fmt.Errorf("%s: %w", im.name, err)
		}
		if string(d[1:6]) != "CD001" {
			if i == 0 {
				return fmt.Errorf("%s: not an ISO 9660 image (missing CD001 magic)", im.name)
			}
			break
		}
		switch d[0] {
		case 1: // Primary Volume Descriptor
			return im.decodePVD(d)
		case 255: // Volume Descriptor Set Terminator
			return fmt.Errorf("%s: ISO 9660 volume descriptor set contains no Primary Volume Descriptor", im.name)
		}
	}
	return fmt.Errorf("%s: ISO 9660 volume descriptor set contains no Primary Volume Descriptor", im.name)
}

func (im *image) decodePVD(d []byte) error {
	im.systemID = strField(d, 8, 32)
	im.volumeID = strField(d, 40, 32)
	im.spaceSize = le32(d, 80)
	im.blockSize = int64(le16(d, 128))
	im.volumeSetID = strField(d, 190, 128)
	im.publisher = strField(d, 318, 128)
	im.dataPreparer = strField(d, 446, 128)
	im.applicationID = strField(d, 574, 128)

	if im.blockSize <= 0 || im.blockSize%512 != 0 || im.blockSize > 65536 {
		return fmt.Errorf("%s: implausible logical block size %d in Primary Volume Descriptor", im.name, im.blockSize)
	}
	// The root directory record is embedded in the PVD at offset 156.
	root := d[156 : 156+34]
	im.rootExtent = le32(root, 2)
	im.rootLength = le32(root, 10)
	im.created, im.hasCreated = parseVolDate(d[813 : 813+17])
	return nil
}

// declaredBytes is volume space size (in logical blocks) times the block size.
func (im *image) declaredBytes() int64 { return int64(im.spaceSize) * im.blockSize }

// ---------------------------------------------------------------------------
// Directory walking
// ---------------------------------------------------------------------------

// Entry is one file or directory found by walking the directory records.
type Entry struct {
	Path      string
	Name      string
	Size      int64
	IsDir     bool
	Extent    uint32
	Offset    int64
	Multi     bool
	ModTime   time.Time
	HasModTim bool
}

// rec is a decoded directory record.
type rec struct {
	name    string
	extent  uint32
	length  uint32
	isDir   bool
	multi   bool
	mtime   time.Time
	hasTime bool
}

// cleanName strips the ";1" ISO 9660 version suffix and the trailing dot that
// mastering tools append to extension-less names.
func cleanName(s string) string {
	if i := strings.LastIndexByte(s, ';'); i >= 0 {
		suffix := s[i+1:]
		digits := suffix != ""
		for _, c := range suffix {
			if c < '0' || c > '9' {
				digits = false
				break
			}
		}
		if digits {
			s = s[:i]
		}
	}
	if len(s) > 1 && strings.HasSuffix(s, ".") {
		s = s[:len(s)-1]
	}
	return s
}

// parseDirBlock decodes every directory record in a directory's data extent.
//
// Two ISO 9660 quirks are load-bearing here:
//   - A record length of 0 means "no more records in this logical sector";
//     the parser must jump to the next sector boundary, otherwise multi-sector
//     directories are misparsed.
//   - Identifiers "\x00" and "\x01" are the "." and ".." entries; they are
//     skipped so recursion cannot loop.
func parseDirBlock(buf []byte, blockSize int) ([]rec, error) {
	var out []rec
	pos := 0
	for pos < len(buf) {
		recLen := int(buf[pos])
		if recLen == 0 {
			// Skip to the next logical sector boundary. This always advances
			// by at least one byte, so the loop cannot spin forever.
			next := (pos/blockSize + 1) * blockSize
			if next <= pos {
				break
			}
			pos = next
			continue
		}
		if recLen < dirRecMin || pos+recLen > len(buf) {
			// Malformed or truncated tail: stop rather than read garbage.
			break
		}
		r := buf[pos : pos+recLen]
		lenFI := int(r[32])
		if dirRecMin+lenFI > recLen {
			break
		}
		id := r[dirRecMin : dirRecMin+lenFI]
		pos += recLen

		// "." and ".." - never recurse into these.
		if lenFI == 1 && (id[0] == 0x00 || id[0] == 0x01) {
			continue
		}
		if lenFI == 0 {
			continue
		}
		name := cleanName(string(id))
		if name == "" {
			continue
		}
		e := rec{
			name:   name,
			extent: le32(r, 2),
			length: le32(r, 10),
			isDir:  r[25]&flagDir != 0,
			multi:  r[25]&flagMultiExtent != 0,
		}
		e.mtime, e.hasTime = parseRecDate(r[18 : 18+7])
		out = append(out, e)
	}
	return out, nil
}

// walk collects every entry beneath the directory at the given extent.
func (im *image) walk(extent, length uint32, prefix string, depth int, seen map[uint32]bool, out *[]Entry) error {
	if depth > maxDepth {
		return fmt.Errorf("directory nesting exceeds %d levels at %q (malformed image?)", maxDepth, prefix+"/")
	}
	if seen[extent] {
		// Cycle guard: a corrupt image can point a directory at an ancestor.
		return nil
	}
	seen[extent] = true
	if length == 0 {
		return nil
	}
	if int64(length) > maxDirBytes {
		return fmt.Errorf("directory %q/ declares an implausible size of %d bytes", prefix, length)
	}
	off := int64(extent) * im.blockSize
	buf, err := im.readAt(off, int(length))
	if err != nil {
		return fmt.Errorf("reading directory %s: %w", displayPath(prefix+"/"), err)
	}
	recs, err := parseDirBlock(buf, int(im.blockSize))
	if err != nil {
		return err
	}
	buf = nil
	for _, r := range recs {
		if len(*out) >= maxEntries {
			return fmt.Errorf("image contains more than %d directory entries (malformed image?)", maxEntries)
		}
		p := prefix + "/" + r.name
		*out = append(*out, Entry{
			Path:      p,
			Name:      r.name,
			Size:      int64(r.length),
			IsDir:     r.isDir,
			Extent:    r.extent,
			Offset:    int64(r.extent) * im.blockSize,
			Multi:     r.multi,
			ModTime:   r.mtime,
			HasModTim: r.hasTime,
		})
		if r.isDir {
			if err := im.walk(r.extent, r.length, p, depth+1, seen, out); err != nil {
				return err
			}
		}
	}
	return nil
}

func (im *image) entries() ([]Entry, error) {
	var out []Entry
	seen := make(map[uint32]bool)
	if err := im.walk(im.rootExtent, im.rootLength, "", 0, seen, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func displayPath(p string) string {
	if p == "" {
		return "/"
	}
	return p
}

// normalizePath turns a user-supplied in-ISO path into the canonical form used
// by walk: a leading slash, no trailing slash, no ";1" version suffix.
func normalizePath(p string) string {
	p = strings.ReplaceAll(p, "\\", "/")
	if !strings.HasPrefix(p, "/") {
		p = "/" + p
	}
	p = path.Clean(p)
	parts := strings.Split(p, "/")
	for i, s := range parts {
		parts[i] = cleanName(s)
	}
	p = strings.Join(parts, "/")
	if p != "/" {
		p = strings.TrimRight(p, "/")
	}
	return p
}

// find locates an entry by path, preferring an exact match and falling back to
// a case-insensitive one (ISO 9660 names are upper-case on disc, so users
// naturally type the lower-case name they used when authoring).
func find(entries []Entry, want string) (Entry, bool) {
	for _, e := range entries {
		if e.Path == want {
			return e, true
		}
	}
	for _, e := range entries {
		if strings.EqualFold(e.Path, want) {
			return e, true
		}
	}
	return Entry{}, false
}

// copyRange streams n bytes from off in the image to w.
func (im *image) copyRange(w io.Writer, off, n int64) error {
	buf := make([]byte, copyChunk)
	for n > 0 {
		c := int64(len(buf))
		if n < c {
			c = n
		}
		got, err := im.f.ReadAt(buf[:c], off)
		if got > 0 {
			if _, werr := w.Write(buf[:got]); werr != nil {
				return werr
			}
			off += int64(got)
			n -= int64(got)
		}
		if int64(got) == c {
			continue
		}
		if err == nil {
			err = io.ErrUnexpectedEOF
		}
		return fmt.Errorf("reading image at offset %d: %w", off, err)
	}
	return nil
}

// ---------------------------------------------------------------------------
// JSON shapes
// ---------------------------------------------------------------------------

type infoJSON struct {
	File             string `json:"file"`
	FileSize         int64  `json:"file_size"`
	FileSizeHuman    string `json:"file_size_human"`
	VolumeID         string `json:"volume_id"`
	SystemID         string `json:"system_id"`
	VolumeSetID      string `json:"volume_set_id"`
	Publisher        string `json:"publisher"`
	DataPreparer     string `json:"data_preparer"`
	ApplicationID    string `json:"application_id"`
	LogicalBlockSize int64  `json:"logical_block_size"`
	VolumeSpaceSize  uint32 `json:"volume_space_size_blocks"`
	TotalBytes       int64  `json:"total_bytes"`
	TotalBytesHuman  string `json:"total_bytes_human"`
	VolumeCreated    string `json:"volume_created,omitempty"`
	RootExtentLBA    uint32 `json:"root_extent_lba"`
	RootSizeBytes    uint32 `json:"root_size_bytes"`
	Truncated        bool   `json:"truncated"`
}

type entryJSON struct {
	Path      string `json:"path"`
	Name      string `json:"name"`
	Size      int64  `json:"size"`
	SizeHuman string `json:"size_human"`
	IsDir     bool   `json:"is_dir"`
	ExtentLBA uint32 `json:"extent_lba"`
	Offset    int64  `json:"offset"`
	Modified  string `json:"modified,omitempty"`
}

type listJSON struct {
	File        string      `json:"file"`
	VolumeID    string      `json:"volume_id"`
	Files       int         `json:"files"`
	Directories int         `json:"directories"`
	TotalBytes  int64       `json:"total_file_bytes"`
	Entries     []entryJSON `json:"entries"`
}

func toEntryJSON(e Entry) entryJSON {
	j := entryJSON{
		Path:      e.Path,
		Name:      e.Name,
		Size:      e.Size,
		SizeHuman: humanBytes(e.Size),
		IsDir:     e.IsDir,
		ExtentLBA: e.Extent,
		Offset:    e.Offset,
	}
	if e.HasModTim {
		j.Modified = e.ModTime.Format(time.RFC3339)
	}
	return j
}

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
}

// parseArgs reorders flags after positionals, then parses. flag.ErrHelp is
// reported as a clean help request.
func parseArgs(fs *flag.FlagSet, args []string, valueFlags map[string]bool) 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 cmdInfo(args []string) error {
	fs := newFlagSet("info")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, nil); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("info takes exactly one argument: <file.iso>")
	}
	im, err := openImage(rest[0])
	if err != nil {
		return err
	}
	defer im.Close()

	declared := im.declaredBytes()
	truncated := declared > im.size

	if *asJSON {
		j := infoJSON{
			File:             im.name,
			FileSize:         im.size,
			FileSizeHuman:    humanBytes(im.size),
			VolumeID:         im.volumeID,
			SystemID:         im.systemID,
			VolumeSetID:      im.volumeSetID,
			Publisher:        im.publisher,
			DataPreparer:     im.dataPreparer,
			ApplicationID:    im.applicationID,
			LogicalBlockSize: im.blockSize,
			VolumeSpaceSize:  im.spaceSize,
			TotalBytes:       declared,
			TotalBytesHuman:  humanBytes(declared),
			RootExtentLBA:    im.rootExtent,
			RootSizeBytes:    im.rootLength,
			Truncated:        truncated,
		}
		if im.hasCreated {
			j.VolumeCreated = im.created.Format(time.RFC3339)
		}
		return writeJSON(j)
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	row := func(k, v string) { fmt.Fprintf(w, "%s\t%s\n", k, v) }
	row("File:", im.name)
	row("File size:", fmt.Sprintf("%d bytes (%s)", im.size, humanBytes(im.size)))
	row("", "")
	row("Volume identifier:", im.volumeID)
	row("System identifier:", im.systemID)
	if im.volumeSetID != "" {
		row("Volume set:", im.volumeSetID)
	}
	if im.publisher != "" {
		row("Publisher:", im.publisher)
	}
	if im.dataPreparer != "" {
		row("Data preparer:", im.dataPreparer)
	}
	if im.applicationID != "" {
		row("Application:", im.applicationID)
	}
	row("Logical block size:", fmt.Sprintf("%d bytes", im.blockSize))
	row("Volume space size:", fmt.Sprintf("%d blocks", im.spaceSize))
	row("Total bytes:", fmt.Sprintf("%d bytes (%s)", declared, humanBytes(declared)))
	if im.hasCreated {
		row("Volume created:", im.created.Format("2006-01-02 15:04:05 -0700"))
	} else {
		row("Volume created:", "(not recorded)")
	}
	row("Root directory:", fmt.Sprintf("extent LBA %d (offset %d), %d bytes",
		im.rootExtent, int64(im.rootExtent)*im.blockSize, im.rootLength))
	if err := w.Flush(); err != nil {
		return err
	}
	if truncated {
		fmt.Fprintf(os.Stderr,
			"isopilot: warning: image is truncated - the volume declares %s but the file is only %s\n",
			humanBytes(declared), humanBytes(im.size))
	}
	return nil
}

func cmdList(args []string) error {
	fs := newFlagSet("list")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, nil); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("list takes exactly one argument: <file.iso>")
	}
	im, err := openImage(rest[0])
	if err != nil {
		return err
	}
	defer im.Close()

	entries, err := im.entries()
	if err != nil {
		return err
	}
	var files, dirs int
	var total int64
	for _, e := range entries {
		if e.IsDir {
			dirs++
			continue
		}
		files++
		total += e.Size
	}

	if *asJSON {
		j := listJSON{
			File:        im.name,
			VolumeID:    im.volumeID,
			Files:       files,
			Directories: dirs,
			TotalBytes:  total,
			Entries:     make([]entryJSON, 0, len(entries)),
		}
		for _, e := range entries {
			j.Entries = append(j.Entries, toEntryJSON(e))
		}
		return writeJSON(j)
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "TYPE\tSIZE\tBYTES\tLBA\tPATH")
	for _, e := range entries {
		kind := "file"
		if e.IsDir {
			kind = "dir"
		}
		name := e.Path
		if e.Multi {
			name += "  (multi-extent: only the first extent is reported)"
		}
		fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", kind, humanBytes(e.Size), e.Size, e.Extent, name)
	}
	if err := w.Flush(); err != nil {
		return err
	}
	fmt.Printf("\n%d files, %d directories, %s of file data\n", files, dirs, humanBytes(total))
	return nil
}

func cmdExtract(args []string) error {
	fs := newFlagSet("extract")
	out := fs.String("out", "", "destination path for the extracted file")
	force := fs.Bool("force", false, "overwrite the destination if it already exists")
	if err := parseArgs(fs, args, map[string]bool{"out": true}); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 2 {
		return errors.New("extract takes exactly two arguments: <file.iso> <path-in-iso>")
	}
	if *out == "" {
		return errors.New("extract requires --out <file>")
	}
	im, err := openImage(rest[0])
	if err != nil {
		return err
	}
	defer im.Close()

	entries, err := im.entries()
	if err != nil {
		return err
	}
	want := normalizePath(rest[1])
	e, ok := find(entries, want)
	if !ok {
		return fmt.Errorf("%s: no such file in image: %s (run \"isopilot list %s\" to see what is there)",
			im.name, want, im.name)
	}
	if e.IsDir {
		return fmt.Errorf("%s is a directory in the image, not a file", e.Path)
	}
	if e.Offset+e.Size > im.size {
		return fmt.Errorf("%s: file data (extent LBA %d, %d bytes) runs past the end of the image - image is truncated",
			e.Path, e.Extent, e.Size)
	}
	if e.Multi {
		fmt.Fprintf(os.Stderr,
			"isopilot: warning: %s is a multi-extent file; only the first extent (%d bytes) is written\n",
			e.Path, e.Size)
	}

	mode := os.O_WRONLY | os.O_CREATE | os.O_EXCL
	if *force {
		if _, serr := os.Stat(*out); serr == nil {
			fmt.Fprintf(os.Stderr, "isopilot: overwriting existing file %s\n", *out)
		}
		mode = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
	}
	df, err := os.OpenFile(*out, mode, 0o644)
	if err != nil {
		if os.IsExist(err) {
			return fmt.Errorf("refusing to overwrite existing file %s (pass --force to overwrite it)", *out)
		}
		return err
	}
	if err := im.copyRange(df, e.Offset, e.Size); err != nil {
		df.Close()
		os.Remove(*out)
		return err
	}
	if err := df.Close(); err != nil {
		os.Remove(*out)
		return err
	}
	fmt.Printf("extracted %s -> %s (%d bytes, %s) from extent LBA %d at offset %d\n",
		e.Path, *out, e.Size, humanBytes(e.Size), e.Extent, e.Offset)
	return nil
}

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

func usageTo(w io.Writer) {
	fmt.Fprint(w, `isopilot - read ISO 9660 disc images: inspect, list, and extract

USAGE
  isopilot <command> [options]

COMMANDS
  info    <file.iso> [--json]                  Parse the Primary Volume Descriptor
  list    <file.iso> [--json]                  Recursively list every directory record
  extract <file.iso> <path-in-iso> --out FILE  Extract one file by extent
  help                                         Show this help

OPTIONS
  --json          Emit machine-readable JSON (info, list)
  --out FILE      Destination for the extracted file (extract, required)
  --force         Allow extract to overwrite an existing --out file
  -h, --help      Show this help

NOTES
  Paths inside an image are absolute and use the on-disc ISO 9660 names, which
  are upper-case and 8.3 by default, e.g. /SUBDIR/INNER.TXT. A ";1" version
  suffix may be omitted, and matching falls back to case-insensitive.
  Flags may appear before or after positional arguments.

EXAMPLES
  isopilot info disc.iso
  isopilot info disc.iso --json
  isopilot list disc.iso
  isopilot extract disc.iso /SUBDIR/INNER.TXT --out inner.txt
  isopilot extract disc.iso subdir/inner.txt --out inner.txt --force
`)
}

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(1)
	}
	args := os.Args[2:]
	var err error
	switch os.Args[1] {
	case "-h", "--help", "help", "-help", "--h":
		usageTo(os.Stdout)
		os.Exit(0)
	case "info":
		err = cmdInfo(args)
	case "list":
		err = cmdList(args)
	case "extract":
		err = cmdExtract(args)
	default:
		fmt.Fprintf(os.Stderr, "isopilot: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "isopilot: %v\n", err)
		os.Exit(1)
	}
}
