package main

// ---------------------------------------------------------------------------
// The independent reader.
//
// This file parses an image back from raw bytes WITHOUT calling into the
// writer. It re-derives every offset from the bytes on disk - it shares no
// struct, no constant table and no helper with fat32.go, layout.go, names.go or
// ptable.go. That is deliberate: round-trip verification is only evidence if
// the two halves are independent. The only thing it borrows from the rest of
// the program is humanBytes(), which formats output and parses nothing.
// ---------------------------------------------------------------------------

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"fmt"
	"hash/crc32"
	"io"
	"os"
	"sort"
	"strings"
	"unicode/utf16"
)

// RPartition is one partition record recovered from a partition table.
type RPartition struct {
	Index      int    `json:"index"`
	Bootable   bool   `json:"bootable,omitempty"`
	Type       string `json:"type"`
	TypeRaw    string `json:"type_raw"`
	FirstLBA   uint64 `json:"first_lba"`
	LastLBA    uint64 `json:"last_lba"`
	Sectors    uint64 `json:"sectors"`
	Bytes      uint64 `json:"bytes"`
	Human      string `json:"human"`
	Name       string `json:"name,omitempty"`
	UniqueGUID string `json:"unique_guid,omitempty"`
	FirstCHS   string `json:"first_chs,omitempty"`
	LastCHS    string `json:"last_chs,omitempty"`
}

// RGPT is the recovered GPT header pair and its checksum verdicts.
type RGPT struct {
	Signature       string `json:"signature"`
	Revision        string `json:"revision"`
	HeaderSize      uint32 `json:"header_size"`
	HeaderCRC       string `json:"header_crc32"`
	HeaderCRCOK     bool   `json:"header_crc32_ok"`
	ArrayCRC        string `json:"array_crc32"`
	ArrayCRCOK      bool   `json:"array_crc32_ok"`
	MyLBA           uint64 `json:"my_lba"`
	AlternateLBA    uint64 `json:"alternate_lba"`
	FirstUsableLBA  uint64 `json:"first_usable_lba"`
	LastUsableLBA   uint64 `json:"last_usable_lba"`
	DiskGUID        string `json:"disk_guid"`
	EntryLBA        uint64 `json:"partition_entry_lba"`
	EntryCount      uint32 `json:"partition_entry_count"`
	EntrySize       uint32 `json:"partition_entry_size"`
	BackupPresent   bool   `json:"backup_header_present"`
	BackupCRCOK     bool   `json:"backup_header_crc32_ok"`
	BackupMyLBA     uint64 `json:"backup_my_lba"`
	BackupArrayCRC  string `json:"backup_array_crc32"`
	BackupArrayMtch bool   `json:"backup_array_matches_primary"`
}

// RBPB is the recovered FAT32 BIOS parameter block.
type RBPB struct {
	OEMName           string `json:"oem_name"`
	JumpInstruction   string `json:"jump_instruction"`
	BytesPerSector    uint32 `json:"bytes_per_sector"`
	SectorsPerCluster uint32 `json:"sectors_per_cluster"`
	ClusterBytes      uint32 `json:"cluster_bytes"`
	ReservedSectors   uint32 `json:"reserved_sectors"`
	NumFATs           uint32 `json:"num_fats"`
	RootEntryCount    uint32 `json:"root_entry_count"`
	TotalSectors16    uint32 `json:"total_sectors_16"`
	MediaDescriptor   string `json:"media_descriptor"`
	FATSize16         uint32 `json:"fat_size_16"`
	SectorsPerTrack   uint32 `json:"sectors_per_track"`
	NumHeads          uint32 `json:"num_heads"`
	HiddenSectors     uint32 `json:"hidden_sectors"`
	TotalSectors32    uint32 `json:"total_sectors_32"`
	FATSize32         uint32 `json:"fat_size_32"`
	ExtFlags          uint32 `json:"ext_flags"`
	FSVersion         uint32 `json:"fs_version"`
	RootCluster       uint32 `json:"root_cluster"`
	FSInfoSector      uint32 `json:"fsinfo_sector"`
	BackupBootSector  uint32 `json:"backup_boot_sector"`
	DriveNumber       string `json:"drive_number"`
	BootSignature     string `json:"boot_signature"`
	VolumeID          string `json:"volume_id"`
	VolumeLabel       string `json:"volume_label"`
	FileSystemType    string `json:"filesystem_type"`
	Signature55AA     bool   `json:"signature_55aa"`
	BackupMatches     bool   `json:"backup_boot_sector_matches"`
	DataStartLBA      uint64 `json:"data_start_lba"`
	ClusterCount      uint32 `json:"cluster_count"`
}

// RFSInfo is the recovered FSInfo sector.
type RFSInfo struct {
	LeadSigOK  bool   `json:"lead_signature_ok"`
	StructSig  bool   `json:"struct_signature_ok"`
	TrailSigOK bool   `json:"trail_signature_ok"`
	FreeCount  uint32 `json:"free_cluster_count"`
	NextFree   uint32 `json:"next_free_cluster"`
}

// RVolume is an opened image, parsed.
type RVolume struct {
	Path       string       `json:"path"`
	ImageBytes int64        `json:"image_bytes"`
	ImageHuman string       `json:"image_human"`
	Scheme     string       `json:"scheme"`
	Partitions []RPartition `json:"partitions"`
	GPT        *RGPT        `json:"gpt,omitempty"`
	FSPartLBA  uint64       `json:"fs_partition_lba"`
	BPB        RBPB         `json:"bpb"`
	FSInfo     RFSInfo      `json:"fsinfo"`
	FATsEqual  bool         `json:"fat_copies_identical"`

	f        *os.File
	secSize  int64
	clusSize int64
	fatStart int64 // absolute byte offset of FAT #1
	fatBytes int64
	dataLBA  int64
	maxClus  uint32
}

// REntry is one recovered directory entry.
type REntry struct {
	Path     string `json:"path"`
	Name     string `json:"name"`
	Short    string `json:"short_name"`
	IsDir    bool   `json:"is_dir"`
	Size     uint32 `json:"size"`
	Cluster  uint32 `json:"first_cluster"`
	Clusters int    `json:"clusters"`
	Attr     string `json:"attributes"`
	LFNSlots int    `json:"lfn_slots"`
	Modified string `json:"modified"`
}

var errNotFAT32 = errors.New("not a FAT32 volume")

// OpenImage opens an image file read-only and parses its tables.
func OpenImage(path string) (*RVolume, error) {
	fi, err := os.Stat(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read image %s: %w", path, err)
	}
	if !fi.Mode().IsRegular() {
		return nil, fmt.Errorf("%s is not a regular file (mode %s); BootBuilder reads image files only", path, fi.Mode())
	}
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("cannot open image %s: %w", path, err)
	}
	v := &RVolume{
		Path: path, ImageBytes: fi.Size(), ImageHuman: humanBytes(fi.Size()),
		f: f, secSize: 512,
	}
	if err := v.parseTables(); err != nil {
		f.Close()
		return nil, err
	}
	if err := v.parseFS(); err != nil {
		f.Close()
		return nil, err
	}
	return v, nil
}

// Close releases the underlying file handle.
func (v *RVolume) Close() error { return v.f.Close() }

func (v *RVolume) readAt(off int64, n int) ([]byte, error) {
	if off < 0 || n < 0 || off+int64(n) > v.ImageBytes {
		return nil, fmt.Errorf("read of %d bytes at offset %d is outside the %d-byte image", n, off, v.ImageBytes)
	}
	b := make([]byte, n)
	if _, err := io.ReadFull(io.NewSectionReader(v.f, off, int64(n)), b); err != nil {
		return nil, fmt.Errorf("cannot read %d bytes at offset %d: %w", n, off, err)
	}
	return b, nil
}

// ---------------------------------------------------------------------------
// Partition tables
// ---------------------------------------------------------------------------

func chsString(b []byte) string {
	h := b[0]
	s := b[1] & 0x3F
	c := (uint16(b[1]&0xC0) << 2) | uint16(b[2])
	return fmt.Sprintf("%d/%d/%d", c, h, s)
}

func mbrTypeName(t byte) string {
	switch t {
	case 0x00:
		return "empty"
	case 0x0B:
		return "FAT32 CHS"
	case 0x0C:
		return "FAT32 LBA"
	case 0x0E:
		return "FAT16 LBA"
	case 0xEE:
		return "GPT protective"
	case 0xEF:
		return "EFI System"
	default:
		return "unknown"
	}
}

func (v *RVolume) parseTables() error {
	sec0, err := v.readAt(0, 512)
	if err != nil {
		return err
	}
	if sec0[510] != 0x55 || sec0[511] != 0xAA {
		return fmt.Errorf("%s has no 0x55AA signature in sector 0; it is not a partitioned image", v.Path)
	}
	protective := false
	for i := 0; i < 4; i++ {
		e := sec0[446+i*16 : 446+i*16+16]
		if e[4] == 0 {
			continue
		}
		start := uint64(binary.LittleEndian.Uint32(e[8:12]))
		count := uint64(binary.LittleEndian.Uint32(e[12:16]))
		if count == 0 {
			continue
		}
		if e[4] == 0xEE {
			protective = true
		}
		v.Partitions = append(v.Partitions, RPartition{
			Index:    i + 1,
			Bootable: e[0] == 0x80,
			Type:     mbrTypeName(e[4]),
			TypeRaw:  fmt.Sprintf("0x%02X", e[4]),
			FirstLBA: start,
			LastLBA:  start + count - 1,
			Sectors:  count,
			Bytes:    count * 512,
			Human:    humanBytes(int64(count) * 512),
			FirstCHS: chsString(e[1:4]),
			LastCHS:  chsString(e[5:8]),
		})
	}
	if !protective {
		v.Scheme = "mbr"
		return nil
	}

	v.Scheme = "gpt"
	hdr, err := v.readAt(512, 512)
	if err != nil {
		return err
	}
	if string(hdr[0:8]) != "EFI PART" {
		return fmt.Errorf("%s has a protective MBR but no \"EFI PART\" header at LBA 1", v.Path)
	}
	g := &RGPT{
		Signature:      string(hdr[0:8]),
		Revision:       fmt.Sprintf("%d.%d", binary.LittleEndian.Uint32(hdr[8:12])>>16, binary.LittleEndian.Uint32(hdr[8:12])&0xFFFF),
		HeaderSize:     binary.LittleEndian.Uint32(hdr[12:16]),
		MyLBA:          binary.LittleEndian.Uint64(hdr[24:32]),
		AlternateLBA:   binary.LittleEndian.Uint64(hdr[32:40]),
		FirstUsableLBA: binary.LittleEndian.Uint64(hdr[40:48]),
		LastUsableLBA:  binary.LittleEndian.Uint64(hdr[48:56]),
		DiskGUID:       guidText(hdr[56:72]),
		EntryLBA:       binary.LittleEndian.Uint64(hdr[72:80]),
		EntryCount:     binary.LittleEndian.Uint32(hdr[80:84]),
		EntrySize:      binary.LittleEndian.Uint32(hdr[84:88]),
	}
	stored := binary.LittleEndian.Uint32(hdr[16:20])
	g.HeaderCRC = fmt.Sprintf("%08X", stored)
	g.HeaderCRCOK = recomputeHeaderCRC(hdr, g.HeaderSize) == stored

	arrBytes := int(g.EntryCount * g.EntrySize)
	arr, err := v.readAt(int64(g.EntryLBA)*512, arrBytes)
	if err != nil {
		return err
	}
	storedArr := binary.LittleEndian.Uint32(hdr[88:92])
	g.ArrayCRC = fmt.Sprintf("%08X", storedArr)
	g.ArrayCRCOK = crc32.ChecksumIEEE(arr) == storedArr

	v.Partitions = nil
	for i := 0; i < int(g.EntryCount); i++ {
		e := arr[i*int(g.EntrySize) : (i+1)*int(g.EntrySize)]
		if isZero(e[0:16]) {
			continue
		}
		first := binary.LittleEndian.Uint64(e[32:40])
		last := binary.LittleEndian.Uint64(e[40:48])
		v.Partitions = append(v.Partitions, RPartition{
			Index:      i + 1,
			Type:       gptTypeName(e[0:16]),
			TypeRaw:    guidText(e[0:16]),
			UniqueGUID: guidText(e[16:32]),
			FirstLBA:   first,
			LastLBA:    last,
			Sectors:    last - first + 1,
			Bytes:      (last - first + 1) * 512,
			Human:      humanBytes(int64(last-first+1) * 512),
			Name:       utf16Name(e[56:128]),
		})
	}

	// Backup header at the last LBA.
	bOff := v.ImageBytes - 512
	if bOff > 0 {
		bh, err := v.readAt(bOff, 512)
		if err == nil && string(bh[0:8]) == "EFI PART" {
			g.BackupPresent = true
			g.BackupMyLBA = binary.LittleEndian.Uint64(bh[24:32])
			bstored := binary.LittleEndian.Uint32(bh[16:20])
			g.BackupCRCOK = recomputeHeaderCRC(bh, binary.LittleEndian.Uint32(bh[12:16])) == bstored
			bArrLBA := binary.LittleEndian.Uint64(bh[72:80])
			g.BackupArrayCRC = fmt.Sprintf("%08X", binary.LittleEndian.Uint32(bh[88:92]))
			if barr, err := v.readAt(int64(bArrLBA)*512, arrBytes); err == nil {
				g.BackupArrayMtch = crc32.ChecksumIEEE(barr) == storedArr && string(barr) == string(arr)
			}
		}
	}
	v.GPT = g
	return nil
}

func recomputeHeaderCRC(hdr []byte, size uint32) uint32 {
	if size < 92 || int(size) > len(hdr) {
		return 0
	}
	tmp := make([]byte, size)
	copy(tmp, hdr[:size])
	binary.LittleEndian.PutUint32(tmp[16:20], 0)
	return crc32.ChecksumIEEE(tmp)
}

func isZero(b []byte) bool {
	for _, c := range b {
		if c != 0 {
			return false
		}
	}
	return true
}

func guidText(g []byte) string {
	return fmt.Sprintf("%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X",
		g[3], g[2], g[1], g[0], g[5], g[4], g[7], g[6], g[8], g[9],
		g[10], g[11], g[12], g[13], g[14], g[15])
}

func gptTypeName(g []byte) string {
	switch guidText(g) {
	case "C12A7328-F81F-11D2-BA4B-00A0C93EC93B":
		return "EFI System Partition"
	case "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7":
		return "Microsoft Basic Data"
	default:
		return "unknown"
	}
}

func utf16Name(b []byte) string {
	u := make([]uint16, 0, len(b)/2)
	for i := 0; i+1 < len(b); i += 2 {
		c := binary.LittleEndian.Uint16(b[i : i+2])
		if c == 0 {
			break
		}
		u = append(u, c)
	}
	return string(utf16.Decode(u))
}

// ---------------------------------------------------------------------------
// Filesystem
// ---------------------------------------------------------------------------

func (v *RVolume) parseFS() error {
	var partLBA uint64
	found := false
	for _, p := range v.Partitions {
		if p.TypeRaw == "0x0C" || p.TypeRaw == "0x0B" || p.Type == "EFI System Partition" {
			partLBA = p.FirstLBA
			found = true
			break
		}
	}
	if !found {
		return fmt.Errorf("%s has no FAT32 or EFI System partition entry", v.Path)
	}
	v.FSPartLBA = partLBA

	base := int64(partLBA) * 512
	bs, err := v.readAt(base, 512)
	if err != nil {
		return err
	}
	b := RBPB{
		JumpInstruction:   fmt.Sprintf("%02X %02X %02X", bs[0], bs[1], bs[2]),
		OEMName:           string(bs[3:11]),
		BytesPerSector:    uint32(binary.LittleEndian.Uint16(bs[11:13])),
		SectorsPerCluster: uint32(bs[13]),
		ReservedSectors:   uint32(binary.LittleEndian.Uint16(bs[14:16])),
		NumFATs:           uint32(bs[16]),
		RootEntryCount:    uint32(binary.LittleEndian.Uint16(bs[17:19])),
		TotalSectors16:    uint32(binary.LittleEndian.Uint16(bs[19:21])),
		MediaDescriptor:   fmt.Sprintf("0x%02X", bs[21]),
		FATSize16:         uint32(binary.LittleEndian.Uint16(bs[22:24])),
		SectorsPerTrack:   uint32(binary.LittleEndian.Uint16(bs[24:26])),
		NumHeads:          uint32(binary.LittleEndian.Uint16(bs[26:28])),
		HiddenSectors:     binary.LittleEndian.Uint32(bs[28:32]),
		TotalSectors32:    binary.LittleEndian.Uint32(bs[32:36]),
		FATSize32:         binary.LittleEndian.Uint32(bs[36:40]),
		ExtFlags:          uint32(binary.LittleEndian.Uint16(bs[40:42])),
		FSVersion:         uint32(binary.LittleEndian.Uint16(bs[42:44])),
		RootCluster:       binary.LittleEndian.Uint32(bs[44:48]),
		FSInfoSector:      uint32(binary.LittleEndian.Uint16(bs[48:50])),
		BackupBootSector:  uint32(binary.LittleEndian.Uint16(bs[50:52])),
		DriveNumber:       fmt.Sprintf("0x%02X", bs[64]),
		BootSignature:     fmt.Sprintf("0x%02X", bs[66]),
		VolumeID: fmt.Sprintf("%04X-%04X",
			binary.LittleEndian.Uint32(bs[67:71])>>16, binary.LittleEndian.Uint32(bs[67:71])&0xFFFF),
		VolumeLabel:    strings.TrimRight(string(bs[71:82]), " "),
		FileSystemType: string(bs[82:90]),
		Signature55AA:  bs[510] == 0x55 && bs[511] == 0xAA,
	}
	if b.BytesPerSector == 0 || b.SectorsPerCluster == 0 || b.NumFATs == 0 || b.FATSize32 == 0 {
		return fmt.Errorf("%w: %s has a degenerate BPB (bytes/sector %d, sectors/cluster %d, FATs %d, FATSz32 %d)",
			errNotFAT32, v.Path, b.BytesPerSector, b.SectorsPerCluster, b.NumFATs, b.FATSize32)
	}
	if b.RootEntryCount != 0 || b.FATSize16 != 0 {
		return fmt.Errorf("%w: %s declares a FAT12/FAT16 root directory", errNotFAT32, v.Path)
	}
	b.ClusterBytes = b.BytesPerSector * b.SectorsPerCluster

	v.secSize = int64(b.BytesPerSector)
	v.clusSize = int64(b.ClusterBytes)
	dataStartRel := int64(b.ReservedSectors) + int64(b.NumFATs)*int64(b.FATSize32)
	v.dataLBA = int64(partLBA) + dataStartRel
	b.DataStartLBA = uint64(v.dataLBA)
	b.ClusterCount = uint32((int64(b.TotalSectors32) - dataStartRel) / int64(b.SectorsPerCluster))
	v.maxClus = b.ClusterCount + 1
	v.fatStart = base + int64(b.ReservedSectors)*v.secSize
	v.fatBytes = int64(b.FATSize32) * v.secSize

	// Backup boot sector at the offset the BPB itself declares.
	if b.BackupBootSector > 0 {
		bb, err := v.readAt(base+int64(b.BackupBootSector)*v.secSize, 512)
		if err == nil {
			b.BackupMatches = string(bb) == string(bs)
		}
	}
	v.BPB = b

	// FSInfo.
	if b.FSInfoSector > 0 {
		fi, err := v.readAt(base+int64(b.FSInfoSector)*v.secSize, 512)
		if err == nil {
			v.FSInfo = RFSInfo{
				LeadSigOK:  binary.LittleEndian.Uint32(fi[0:4]) == 0x41615252,
				StructSig:  binary.LittleEndian.Uint32(fi[484:488]) == 0x61417272,
				TrailSigOK: binary.LittleEndian.Uint32(fi[508:512]) == 0xAA550000,
				FreeCount:  binary.LittleEndian.Uint32(fi[488:492]),
				NextFree:   binary.LittleEndian.Uint32(fi[492:496]),
			}
		}
	}

	// Compare the FAT copies byte for byte.
	v.FATsEqual = true
	if b.NumFATs > 1 {
		first, err := v.readAt(v.fatStart, int(v.fatBytes))
		if err != nil {
			return err
		}
		for i := uint32(1); i < b.NumFATs; i++ {
			other, err := v.readAt(v.fatStart+int64(i)*v.fatBytes, int(v.fatBytes))
			if err != nil {
				return err
			}
			if string(first) != string(other) {
				v.FATsEqual = false
			}
		}
	}
	return nil
}

// fatEntry returns the FAT value for a cluster, masked to 28 bits.
func (v *RVolume) fatEntry(cluster uint32) (uint32, error) {
	off := v.fatStart + int64(cluster)*4
	if off+4 > v.fatStart+v.fatBytes {
		return 0, fmt.Errorf("cluster %d is outside the %d-byte FAT", cluster, v.fatBytes)
	}
	b, err := v.readAt(off, 4)
	if err != nil {
		return 0, err
	}
	return binary.LittleEndian.Uint32(b) & 0x0FFFFFFF, nil
}

// Chain follows a cluster chain and returns every cluster in it.
func (v *RVolume) Chain(start uint32) ([]uint32, error) {
	if start < 2 {
		return nil, nil
	}
	var out []uint32
	seen := map[uint32]bool{}
	c := start
	for {
		if c < 2 || c > v.maxClus {
			return nil, fmt.Errorf("cluster %d is outside the valid range 2..%d", c, v.maxClus)
		}
		if seen[c] {
			return nil, fmt.Errorf("cluster chain starting at %d loops back to %d", start, c)
		}
		seen[c] = true
		out = append(out, c)
		n, err := v.fatEntry(c)
		if err != nil {
			return nil, err
		}
		if n >= 0x0FFFFFF8 {
			return out, nil // end of chain
		}
		if n == 0 {
			return nil, fmt.Errorf("cluster chain starting at %d runs into a free cluster at %d", start, c)
		}
		if n == 0x0FFFFFF7 {
			return nil, fmt.Errorf("cluster chain starting at %d runs into a bad cluster at %d", start, c)
		}
		c = n
	}
}

func (v *RVolume) clusterOffset(c uint32) int64 {
	return (v.dataLBA + int64(c-2)*int64(v.BPB.SectorsPerCluster)) * v.secSize
}

// readChainInto streams up to limit bytes of a cluster chain into w.
func (v *RVolume) readChainInto(w io.Writer, start uint32, limit int64) error {
	if limit == 0 {
		return nil
	}
	chain, err := v.Chain(start)
	if err != nil {
		return err
	}
	var done int64
	for _, c := range chain {
		n := v.clusSize
		if limit > 0 && limit-done < n {
			n = limit - done
		}
		if n <= 0 {
			break
		}
		b, err := v.readAt(v.clusterOffset(c), int(n))
		if err != nil {
			return err
		}
		if _, err := w.Write(b); err != nil {
			return err
		}
		done += n
	}
	if limit > 0 && done < limit {
		return fmt.Errorf("chain at cluster %d holds %d bytes but the directory entry claims %d", start, done, limit)
	}
	return nil
}

// lfnChecksum recomputes the OSTA short-name checksum. Written here from the
// specification rather than reused from the writer.
func lfnChecksum(name []byte) byte {
	var s byte
	for i := 0; i < 11; i++ {
		if s&1 != 0 {
			s = 0x80 + (s >> 1) + name[i]
		} else {
			s = (s >> 1) + name[i]
		}
	}
	return s
}

func attrText(a byte) string {
	var p []string
	for _, x := range []struct {
		bit  byte
		name string
	}{{0x01, "read-only"}, {0x02, "hidden"}, {0x04, "system"}, {0x08, "volume-id"}, {0x10, "directory"}, {0x20, "archive"}} {
		if a&x.bit != 0 {
			p = append(p, x.name)
		}
	}
	if len(p) == 0 {
		return "none"
	}
	return strings.Join(p, ",")
}

func fatTimeText(date, tm uint16) string {
	y := 1980 + int(date>>9)
	mo := int(date>>5) & 0x0F
	d := int(date) & 0x1F
	h := int(tm >> 11)
	mi := int(tm>>5) & 0x3F
	s := (int(tm) & 0x1F) * 2
	return fmt.Sprintf("%04d-%02d-%02dT%02d:%02d:%02d", y, mo, d, h, mi, s)
}

// ReadDir parses one directory's cluster chain into entries. Deleted records
// and the "." / ".." links are omitted; the volume-label record is returned
// separately by VolumeLabelEntry.
func (v *RVolume) ReadDir(cluster uint32, dirPath string) ([]REntry, error) {
	var raw []byte
	buf := &byteSink{}
	if err := v.readChainInto(buf, cluster, -1); err != nil {
		return nil, err
	}
	raw = buf.b

	var out []REntry
	var pending [][]byte
	for off := 0; off+32 <= len(raw); off += 32 {
		e := raw[off : off+32]
		if e[0] == 0x00 {
			break // end of directory
		}
		if e[0] == 0xE5 {
			pending = nil
			continue
		}
		attr := e[11]
		if attr&0x3F == 0x0F {
			pending = append(pending, append([]byte(nil), e...))
			continue
		}
		short := shortText(e[0:11])
		if attr&0x08 != 0 && attr&0x10 == 0 {
			pending = nil
			continue // volume label record
		}
		if short == "." || short == ".." {
			pending = nil
			continue
		}
		long, slots, err := assembleLFN(pending, e[0:11])
		if err != nil {
			return nil, fmt.Errorf("%s: %w", dirPath, err)
		}
		pending = nil
		name := long
		if name == "" {
			name = short
		}
		cl := uint32(binary.LittleEndian.Uint16(e[20:22]))<<16 | uint32(binary.LittleEndian.Uint16(e[26:28]))
		size := binary.LittleEndian.Uint32(e[28:32])
		ent := REntry{
			Path:     strings.TrimPrefix(dirPath+"/"+name, "/"),
			Name:     name,
			Short:    short,
			IsDir:    attr&0x10 != 0,
			Size:     size,
			Cluster:  cl,
			Attr:     attrText(attr),
			LFNSlots: slots,
			Modified: fatTimeText(binary.LittleEndian.Uint16(e[24:26]), binary.LittleEndian.Uint16(e[22:24])),
		}
		if cl >= 2 {
			ch, err := v.Chain(cl)
			if err != nil {
				return nil, fmt.Errorf("%s: %w", ent.Path, err)
			}
			ent.Clusters = len(ch)
		}
		out = append(out, ent)
	}
	return out, nil
}

// assembleLFN rebuilds a long name from the slots that preceded a short entry,
// checking the ordering flags and the checksum in every slot.
func assembleLFN(pending [][]byte, short []byte) (string, int, error) {
	if len(pending) == 0 {
		return "", 0, nil
	}
	want := lfnChecksum(short)
	type slot struct {
		ord   int
		units []uint16
	}
	var slots []slot
	for i, e := range pending {
		if e[13] != want {
			return "", 0, fmt.Errorf("long-name slot %d carries checksum 0x%02X but its short name %q checksums to 0x%02X",
				i, e[13], shortText(short), want)
		}
		ord := int(e[0] & 0x3F)
		last := e[0]&0x40 != 0
		if i == 0 && !last {
			return "", 0, fmt.Errorf("first long-name slot on disk is not flagged LAST_LONG_ENTRY (ord 0x%02X)", e[0])
		}
		if i > 0 && last {
			return "", 0, fmt.Errorf("long-name slot %d is flagged LAST_LONG_ENTRY but is not first on disk", i)
		}
		if ord != len(pending)-i {
			return "", 0, fmt.Errorf("long-name slot %d has ordinal %d; expected %d for reverse order", i, ord, len(pending)-i)
		}
		offs := []int{1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30}
		var u []uint16
		for _, o := range offs {
			u = append(u, binary.LittleEndian.Uint16(e[o:o+2]))
		}
		slots = append(slots, slot{ord: ord, units: u})
	}
	sort.Slice(slots, func(i, j int) bool { return slots[i].ord < slots[j].ord })
	var all []uint16
	for _, s := range slots {
		for _, c := range s.units {
			if c == 0x0000 || c == 0xFFFF {
				continue
			}
			all = append(all, c)
		}
	}
	return string(utf16.Decode(all)), len(pending), nil
}

func shortText(raw []byte) string {
	b := strings.TrimRight(string(raw[0:8]), " ")
	e := strings.TrimRight(string(raw[8:11]), " ")
	if e == "" {
		return b
	}
	return b + "." + e
}

// Walk visits every entry in the volume depth first, parents before children.
func (v *RVolume) Walk(fn func(REntry) error) error {
	var rec func(cluster uint32, dirPath string, depth int) error
	rec = func(cluster uint32, dirPath string, depth int) error {
		if depth > 64 {
			return fmt.Errorf("directory nesting deeper than 64 levels at %s", dirPath)
		}
		ents, err := v.ReadDir(cluster, dirPath)
		if err != nil {
			return err
		}
		for _, e := range ents {
			if err := fn(e); err != nil {
				return err
			}
			if e.IsDir {
				if err := rec(e.Cluster, e.Path, depth+1); err != nil {
					return err
				}
			}
		}
		return nil
	}
	return rec(v.BPB.RootCluster, "", 0)
}

// HashFile returns the SHA-256 of one file's contents, read through its FAT
// chain and truncated at the size in its directory entry.
func (v *RVolume) HashFile(e REntry) (string, error) {
	h := sha256.New()
	if e.Size > 0 {
		if err := v.readChainInto(h, e.Cluster, int64(e.Size)); err != nil {
			return "", err
		}
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// VolumeLabelEntry returns the label recorded in the root directory, if any.
func (v *RVolume) VolumeLabelEntry() (string, error) {
	buf := &byteSink{}
	if err := v.readChainInto(buf, v.BPB.RootCluster, -1); err != nil {
		return "", err
	}
	for off := 0; off+32 <= len(buf.b); off += 32 {
		e := buf.b[off : off+32]
		if e[0] == 0x00 {
			break
		}
		if e[0] == 0xE5 {
			continue
		}
		if e[11]&0x3F == 0x0F {
			continue
		}
		if e[11]&0x08 != 0 && e[11]&0x10 == 0 {
			return strings.TrimRight(string(e[0:11]), " "), nil
		}
	}
	return "", nil
}

// byteSink accumulates bytes for the small reads (directories) that need them
// all at once.
type byteSink struct{ b []byte }

func (s *byteSink) Write(p []byte) (int, error) {
	s.b = append(s.b, p...)
	return len(p), nil
}
