package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"testing"
	"time"
)

// ---------------------------------------------------------------------------
// Test harness: build the real binary once and drive it as a subprocess, so the
// end-to-end tests exercise exactly what ships.
// ---------------------------------------------------------------------------

var binPath string

func TestMain(m *testing.M) {
	dir, err := os.MkdirTemp("", "movephone-bin-")
	if err != nil {
		fmt.Fprintln(os.Stderr, "cannot make temp dir:", err)
		os.Exit(1)
	}
	binPath = filepath.Join(dir, "movephone")
	build := exec.Command("go", "build", "-o", binPath, ".")
	build.Stderr = os.Stderr
	if err := build.Run(); err != nil {
		fmt.Fprintln(os.Stderr, "cannot build movephone:", err)
		os.RemoveAll(dir)
		os.Exit(1)
	}
	code := m.Run()
	os.RemoveAll(dir)
	os.Exit(code)
}

type runResult struct {
	stdout string
	stderr string
	code   int
}

func run(t *testing.T, args ...string) runResult {
	t.Helper()
	cmd := exec.Command(binPath, args...)
	var so, se bytes.Buffer
	cmd.Stdout, cmd.Stderr = &so, &se
	err := cmd.Run()
	code := 0
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		code = ee.ExitCode()
	} else if err != nil {
		t.Fatalf("running %v: %v", args, err)
	}
	return runResult{so.String(), se.String(), code}
}

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

func writeFile(t *testing.T, root, rel string, data []byte) string {
	t.Helper()
	p := filepath.Join(root, filepath.FromSlash(rel))
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(p, data, 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func sum(b []byte) string {
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

// filler produces deterministic, non-repeating-per-file bytes.
func filler(seed byte, n int) []byte {
	b := make([]byte, n)
	x := uint32(seed)*2654435761 + 1
	for i := range b {
		x = x*1664525 + 1013904223
		b[i] = byte(x >> 16)
	}
	return b
}

// Real container headers, so the sniffer is tested against the actual byte
// layouts and not against a mock.
func jpegBytes(seed byte) []byte {
	return append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0}, filler(seed, 200)...)
}
func pngBytes(seed byte) []byte {
	return append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, 'I', 'H', 'D', 'R'}, filler(seed, 200)...)
}
func gifBytes(seed byte) []byte {
	return append([]byte("GIF89a"), filler(seed, 120)...)
}
func pdfBytes(seed byte) []byte {
	return append([]byte("%PDF-1.7\n"), filler(seed, 300)...)
}
func zipBytes(seed byte) []byte {
	return append([]byte{'P', 'K', 0x03, 0x04, 0x14, 0x00}, filler(seed, 150)...)
}
func ftypBytes(major string, compat string, seed byte, n int) []byte {
	head := []byte{0, 0, 0, 0x20}
	head = append(head, []byte("ftyp")...)
	head = append(head, []byte(major)...)
	head = append(head, 0, 0, 2, 0)
	head = append(head, []byte(compat)...)
	return append(head, filler(seed, n)...)
}
func mp4Bytes(seed byte) []byte  { return ftypBytes("isom", "isomiso2avc1mp41", seed, 400) }
func movBytes(seed byte) []byte  { return ftypBytes("qt  ", "qt  ", seed, 400) }
func heicBytes(seed byte) []byte { return ftypBytes("heic", "mif1heic", seed, 400) }
func legacyMov(seed byte) []byte {
	return append([]byte{0, 0, 0, 0x10, 'm', 'o', 'o', 'v'}, filler(seed, 200)...)
}

// treeHashes maps every relative path in a tree to its SHA-256 and size.
func treeHashes(t *testing.T, root string) map[string]string {
	t.Helper()
	out := map[string]string{}
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || !d.Type().IsRegular() {
			return nil
		}
		b, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		rel, _ := filepath.Rel(root, p)
		out[filepath.ToSlash(rel)] = fmt.Sprintf("%s:%d", sum(b), len(b))
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return out
}

func keysOf(m map[string]string) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// ---------------------------------------------------------------------------
// Content sniffing
// ---------------------------------------------------------------------------

func TestSniff(t *testing.T) {
	cases := []struct {
		name       string
		data       []byte
		filename   string
		wantKind   string
		wantMethod string
		wantCat    string
	}{
		{"jpeg by magic", jpegBytes(1), "IMG_0001.JPG", "image/jpeg", byMagic, "photo"},
		{"jpeg magic beats a lying extension", jpegBytes(2), "note.txt", "image/jpeg", byMagic, "photo"},
		{"png by magic", pngBytes(3), "shot.png", "image/png", byMagic, "photo"},
		{"png magic with no extension", pngBytes(4), "screenshot", "image/png", byMagic, "photo"},
		{"gif87a by magic", append([]byte("GIF87a"), 1, 2, 3), "old.gif", "image/gif", byMagic, "photo"},
		{"gif89a by magic", gifBytes(5), "anim.gif", "image/gif", byMagic, "photo"},
		{"pdf by magic", pdfBytes(6), "receipt.pdf", "application/pdf", byMagic, "document"},
		{"zip by magic", zipBytes(7), "backup.zip", "application/zip", byMagic, "archive"},
		{"empty zip by magic", []byte{'P', 'K', 0x05, 0x06, 0, 0}, "empty.zip", "application/zip", byMagic, "archive"},
		{"mp4 by ftyp isom", mp4Bytes(8), "VID_001.mp4", "video/mp4", byMagic, "video"},
		{"mp4 ftyp beats a .mov extension", mp4Bytes(9), "clip.mov", "video/mp4", byMagic, "video"},
		{"mov by ftyp qt", movBytes(10), "VID_002.mov", "video/quicktime", byMagic, "video"},
		{"mov by legacy moov atom", legacyMov(11), "ancient.mov", "video/quicktime", byMagic, "video"},
		{"heic by ftyp heic", heicBytes(12), "IMG_9000.HEIC", "image/heic", byMagic, "photo"},
		{"heic mislabelled .jpg is still heic", heicBytes(13), "IMG_9001.jpg", "image/heic", byMagic, "photo"},
		{"heic via mif1 major brand", ftypBytes("mif1", "mif1heic", 14, 100), "x.heic", "image/heic", byMagic, "photo"},
		{"avif kept distinct from heic", ftypBytes("avif", "avifmif1", 15, 100), "x.avif", "image/avif", byMagic, "photo"},
		{"unknown ftyp brand falls back to mp4", ftypBytes("zzzz", "zzzzyyyy", 16, 100), "x.bin", "video/mp4", byMagic, "video"},
		{"unknown ftyp brand with known compat brand", ftypBytes("zzzz", "qt  ", 17, 100), "x.bin", "video/quicktime", byMagic, "video"},
		{"docx is a zip refined by extension", zipBytes(18), "report.docx", zipRefine[".docx"], byMagicExt, "document"},
		{"apk is a zip refined by extension", zipBytes(19), "app.apk", zipRefine[".apk"], byMagicExt, "archive"},
		{"webp has no magic here, extension used", filler(20, 100), "pic.webp", "image/webp", byExtension, "photo"},
		{"vcf by extension", []byte("BEGIN:VCARD\n"), "contacts.vcf", "text/vcard", byExtension, "document"},
		{"mp3 by extension", filler(21, 100), "song.mp3", "audio/mpeg", byExtension, "audio"},
		{"extension is case insensitive", filler(22, 100), "PIC.WEBP", "image/webp", byExtension, "photo"},
		{"nothing identifies it", filler(23, 100), "mystery", kindUnknown, byNothing, "other"},
		{"unknown extension is not invented", filler(24, 100), "thing.qqq", kindUnknown, byNothing, "other"},
		{"empty file", nil, "empty", kindUnknown, byNothing, "other"},
		{"truncated jpeg magic is not a jpeg", []byte{0xFF, 0xD8}, "short.dat", kindUnknown, byNothing, "other"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			head := c.data
			if len(head) > sniffLen {
				head = head[:sniffLen]
			}
			kind, method := sniff(head, c.filename)
			if kind != c.wantKind {
				t.Errorf("kind = %q, want %q", kind, c.wantKind)
			}
			if method != c.wantMethod {
				t.Errorf("method = %q, want %q", method, c.wantMethod)
			}
			if cat := categoryOf(kind); cat != c.wantCat {
				t.Errorf("category = %q, want %q", cat, c.wantCat)
			}
		})
	}
}

func TestInventoryReportsDetectionMethodAndDedup(t *testing.T) {
	src := t.TempDir()
	writeFile(t, src, "DCIM/a.jpg", jpegBytes(1))
	writeFile(t, src, "DCIM/copy-of-a.jpg", jpegBytes(1)) // identical content
	writeFile(t, src, "DCIM/b.HEIC", heicBytes(2))
	writeFile(t, src, "Docs/x.webp", filler(3, 500)) // extension only
	writeFile(t, src, "Docs/mystery", filler(4, 700))

	inv, err := scanTree(src)
	if err != nil {
		t.Fatal(err)
	}
	if inv.Files != 5 {
		t.Fatalf("files = %d, want 5", inv.Files)
	}
	if inv.UniqueFiles != 4 {
		t.Errorf("unique contents = %d, want 4", inv.UniqueFiles)
	}
	wantRedundant := int64(len(jpegBytes(1)))
	if inv.RedundantB != wantRedundant {
		t.Errorf("redundant bytes = %d, want %d", inv.RedundantB, wantRedundant)
	}
	if inv.Bytes-inv.UniqueBytes != inv.RedundantB {
		t.Errorf("dedup-adjusted arithmetic is inconsistent")
	}
	if inv.ByMagic != 3 {
		t.Errorf("identified by magic = %d, want 3", inv.ByMagic)
	}
	if inv.ByExtension != 1 {
		t.Errorf("identified by extension = %d, want 1", inv.ByExtension)
	}
	if inv.Unidentified != 1 {
		t.Errorf("unidentified = %d, want 1", inv.Unidentified)
	}
	// Items are sorted, which is what makes the plan deterministic.
	for i := 1; i < len(inv.Items); i++ {
		if inv.Items[i-1].Rel >= inv.Items[i].Rel {
			t.Fatalf("inventory is not sorted: %q then %q", inv.Items[i-1].Rel, inv.Items[i].Rel)
		}
	}
}

// ---------------------------------------------------------------------------
// Plan classification: all four categories
// ---------------------------------------------------------------------------

func TestPlanClassification(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()

	// new
	writeFile(t, src, "DCIM/new.jpg", jpegBytes(1))
	// already-present-identical
	writeFile(t, src, "DCIM/same.jpg", jpegBytes(2))
	writeFile(t, dst, "DCIM/same.jpg", jpegBytes(2))
	// name-collision-different-content
	writeFile(t, src, "DCIM/clash.jpg", jpegBytes(3))
	writeFile(t, dst, "DCIM/clash.jpg", jpegBytes(99))
	// duplicate-within-source (a.jpg sorts before z.jpg)
	writeFile(t, src, "DCIM/a-original.jpg", jpegBytes(4))
	writeFile(t, src, "DCIM/z-duplicate.jpg", jpegBytes(4))

	srcInv, err := scanTree(src)
	if err != nil {
		t.Fatal(err)
	}
	dstInv, err := scanTree(dst)
	if err != nil {
		t.Fatal(err)
	}
	p := buildPlan(srcInv, dstInv, nil)

	got := map[string]PlanItem{}
	for _, it := range p.Items {
		got[it.Rel] = it
	}

	cases := []struct {
		rel        string
		wantClass  string
		wantAction string
	}{
		{"DCIM/new.jpg", classNew, "transfer"},
		{"DCIM/same.jpg", classIdentical, "skip"},
		{"DCIM/clash.jpg", classCollision, "transfer"},
		{"DCIM/a-original.jpg", classNew, "transfer"},
		{"DCIM/z-duplicate.jpg", classDupSource, "transfer"},
	}
	for _, c := range cases {
		t.Run(c.rel, func(t *testing.T) {
			it, ok := got[c.rel]
			if !ok {
				t.Fatalf("%s missing from plan", c.rel)
			}
			if it.Class != c.wantClass {
				t.Errorf("class = %q, want %q", it.Class, c.wantClass)
			}
			if it.Action != c.wantAction {
				t.Errorf("action = %q, want %q", it.Action, c.wantAction)
			}
		})
	}
	if p.New != 2 || p.Identical != 1 || p.Collisions != 1 || p.Duplicates != 1 {
		t.Errorf("counts new=%d identical=%d collisions=%d duplicates=%d, want 2/1/1/1",
			p.New, p.Identical, p.Collisions, p.Duplicates)
	}
	// The collision must be routed to the documented disambiguated name.
	clash := got["DCIM/clash.jpg"]
	want := disambiguate("DCIM/clash.jpg", clash.SHA256)
	if clash.DestRel != want {
		t.Errorf("collision dest = %q, want %q", clash.DestRel, want)
	}
	if !strings.Contains(clash.DestRel, collisionMarker) || !strings.HasSuffix(clash.DestRel, ".jpg") {
		t.Errorf("disambiguated name %q must carry the marker and keep the extension", clash.DestRel)
	}
	// Bytes to move must exclude the identical file and include the duplicate.
	wantBytes := got["DCIM/new.jpg"].Bytes + got["DCIM/clash.jpg"].Bytes +
		got["DCIM/a-original.jpg"].Bytes + got["DCIM/z-duplicate.jpg"].Bytes
	if p.MoveBytes != wantBytes {
		t.Errorf("bytes to move = %d, want %d", p.MoveBytes, wantBytes)
	}
	if p.RedundantMove != got["DCIM/z-duplicate.jpg"].Bytes {
		t.Errorf("redundant bytes = %d, want %d", p.RedundantMove, got["DCIM/z-duplicate.jpg"].Bytes)
	}
}

func TestPlanIsIdempotentAfterCollisionTransfer(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "DCIM/clash.jpg", jpegBytes(3))
	writeFile(t, dst, "DCIM/clash.jpg", jpegBytes(99))

	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply")
	if r.code != 0 {
		t.Fatalf("transfer failed: %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	// Planning again must NOT report the collision a second time: the
	// disambiguated file is now present with identical content.
	r = run(t, "plan", "--src", src, "--dst", dst, "--json")
	if r.code != 0 {
		t.Fatalf("plan failed: %d\n%s", r.code, r.stderr)
	}
	var p Plan
	if err := json.Unmarshal([]byte(r.stdout), &p); err != nil {
		t.Fatal(err)
	}
	if p.Collisions != 0 {
		t.Errorf("second plan reports %d collisions, want 0", p.Collisions)
	}
	if p.Identical != 1 {
		t.Errorf("second plan reports %d already-present-identical, want 1", p.Identical)
	}
	if p.MoveBytes != 0 {
		t.Errorf("second plan wants to move %d bytes, want 0", p.MoveBytes)
	}
}

// ---------------------------------------------------------------------------
// Dry run
// ---------------------------------------------------------------------------

func TestDryRunLeavesDestinationUntouched(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledgerDir := t.TempDir()
	ledger := filepath.Join(ledgerDir, "l.jsonl")

	writeFile(t, src, "DCIM/a.jpg", jpegBytes(1))
	writeFile(t, src, "DCIM/b.mp4", mp4Bytes(2))
	writeFile(t, dst, "DCIM/a.jpg", jpegBytes(50)) // a collision, still not written

	before := treeHashes(t, dst)

	for _, args := range [][]string{
		{"plan", "--src", src, "--dst", dst},
		{"transfer", "--src", src, "--dst", dst, "--ledger", ledger},
	} {
		r := run(t, args...)
		if r.code != 0 {
			t.Fatalf("%v exited %d\n%s%s", args, r.code, r.stdout, r.stderr)
		}
		after := treeHashes(t, dst)
		if len(after) != len(before) {
			t.Fatalf("%v changed the destination: %v -> %v", args, keysOf(before), keysOf(after))
		}
		for k, v := range before {
			if after[k] != v {
				t.Fatalf("%v modified %s", args, k)
			}
		}
	}
	if _, err := os.Stat(ledger); !os.IsNotExist(err) {
		t.Errorf("dry run created a ledger at %s", ledger)
	}
	// The dry run must still say what it would do.
	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger)
	if !strings.Contains(r.stdout, "DRY RUN") || !strings.Contains(r.stdout, "--apply") {
		t.Errorf("dry run output does not announce itself:\n%s", r.stdout)
	}
}

func TestDryRunDoesNotCreateAMissingDestination(t *testing.T) {
	src := t.TempDir()
	dst := filepath.Join(t.TempDir(), "newphone")
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))

	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger)
	if r.code != 0 {
		t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	if _, err := os.Stat(dst); !os.IsNotExist(err) {
		t.Errorf("dry run created the destination directory %s", dst)
	}
}

// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------

func TestApplyTransfersByteIdentically(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")

	files := map[string][]byte{
		"DCIM/Camera/IMG_0001.JPG":     jpegBytes(1),
		"DCIM/Camera/IMG_0002.HEIC":    heicBytes(2),
		"DCIM/Camera/VID_0001.mp4":     mp4Bytes(3),
		"DCIM/Camera/VID_0002.mov":     movBytes(4),
		"Download/manual.pdf":          pdfBytes(5),
		"Download/nested/deep/big.bin": filler(6, 300000),
		"WhatsApp/Media/anim.gif":      gifBytes(7),
		"empty.dat":                    {},
	}
	for rel, data := range files {
		writeFile(t, src, rel, data)
	}
	srcBefore := treeHashes(t, src)

	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply", "--json")
	if r.code != 0 {
		t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	var rep TransferReport
	if err := json.Unmarshal([]byte(r.stdout), &rep); err != nil {
		t.Fatal(err)
	}
	if rep.Failed != 0 {
		t.Fatalf("failed = %d, want 0", rep.Failed)
	}
	if rep.Transferred != len(files) {
		t.Errorf("transferred = %d, want %d", rep.Transferred, len(files))
	}

	for rel, want := range files {
		got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
		if err != nil {
			t.Fatalf("%s: %v", rel, err)
		}
		if !bytes.Equal(got, want) {
			t.Errorf("%s is not byte-identical (%d vs %d bytes)", rel, len(got), len(want))
		}
	}
	// No temporary files left behind.
	filepath.WalkDir(dst, func(p string, d fs.DirEntry, err error) error {
		if err == nil && !d.IsDir() && strings.HasSuffix(p, ".part") {
			t.Errorf("leftover partial file %s", p)
		}
		return nil
	})
	// The source is untouched.
	if diff := diffTrees(srcBefore, treeHashes(t, src)); diff != "" {
		t.Errorf("source tree was modified: %s", diff)
	}
	// The ledger records every item as verified.
	entries, err := loadLedger(ledger)
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != len(files) {
		t.Errorf("ledger has %d entries, want %d", len(entries), len(files))
	}
	for _, e := range entries {
		if e.Status != statusVerified {
			t.Errorf("ledger entry %s status = %q, want %q", e.Rel, e.Status, statusVerified)
		}
	}
	// A second apply is a no-op: everything is already identical.
	r = run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply", "--json")
	var rep2 TransferReport
	if err := json.Unmarshal([]byte(r.stdout), &rep2); err != nil {
		t.Fatal(err)
	}
	if rep2.Transferred != 0 {
		t.Errorf("second apply transferred %d files, want 0", rep2.Transferred)
	}
}

func diffTrees(before, after map[string]string) string {
	var out []string
	for k, v := range before {
		if av, ok := after[k]; !ok {
			out = append(out, "removed "+k)
		} else if av != v {
			out = append(out, "changed "+k)
		}
	}
	for k := range after {
		if _, ok := before[k]; !ok {
			out = append(out, "added "+k)
		}
	}
	sort.Strings(out)
	return strings.Join(out, ", ")
}

// ---------------------------------------------------------------------------
// Collisions
// ---------------------------------------------------------------------------

func TestCollisionNeverOverwrites(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")

	incoming := jpegBytes(1)
	existing := jpegBytes(2)
	writeFile(t, src, "DCIM/IMG_0001.JPG", incoming)
	writeFile(t, dst, "DCIM/IMG_0001.JPG", existing)
	// A second collision with no extension at all.
	writeFile(t, src, "notes", []byte("new notes"))
	writeFile(t, dst, "notes", []byte("old notes"))

	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply", "--json")
	if r.code != 0 {
		t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	var rep TransferReport
	if err := json.Unmarshal([]byte(r.stdout), &rep); err != nil {
		t.Fatal(err)
	}
	if rep.Collisions != 2 {
		t.Errorf("collisions = %d, want 2", rep.Collisions)
	}

	// The pre-existing files are byte-for-byte what they were.
	if got, _ := os.ReadFile(filepath.Join(dst, "DCIM", "IMG_0001.JPG")); !bytes.Equal(got, existing) {
		t.Fatal("existing DCIM/IMG_0001.JPG was overwritten")
	}
	if got, _ := os.ReadFile(filepath.Join(dst, "notes")); !bytes.Equal(got, []byte("old notes")) {
		t.Fatal("existing notes was overwritten")
	}

	// The incoming files landed under the documented disambiguated names.
	wantJPG := disambiguate("DCIM/IMG_0001.JPG", sum(incoming))
	got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(wantJPG)))
	if err != nil {
		t.Fatalf("expected disambiguated file %s: %v", wantJPG, err)
	}
	if !bytes.Equal(got, incoming) {
		t.Errorf("%s does not hold the incoming content", wantJPG)
	}
	if !strings.HasSuffix(wantJPG, ".JPG") {
		t.Errorf("disambiguated name %q lost the original extension", wantJPG)
	}
	wantNotes := disambiguate("notes", sum([]byte("new notes")))
	if got, err := os.ReadFile(filepath.Join(dst, wantNotes)); err != nil || !bytes.Equal(got, []byte("new notes")) {
		t.Errorf("extensionless collision did not land at %s: %v", wantNotes, err)
	}

	// It is reported, not silent.
	r = run(t, "plan", "--src", src, "--dst", dst)
	_ = r
	r2 := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply")
	if !strings.Contains(r2.stdout, "0 overwritten") {
		t.Errorf("transfer output does not report collision handling:\n%s", r2.stdout)
	}
}

func TestTwoDifferentSourceFilesCollidingOnOneNameGetSeparateHomes(t *testing.T) {
	// Two source files at DIFFERENT paths cannot collide with each other, but a
	// disambiguated name must never be handed out twice.
	src, dst := t.TempDir(), t.TempDir()
	a, b := jpegBytes(1), jpegBytes(2)
	writeFile(t, src, "x.jpg", a)
	writeFile(t, dst, "x.jpg", jpegBytes(3))

	srcInv, _ := scanTree(src)
	dstInv, _ := scanTree(dst)
	p := buildPlan(srcInv, dstInv, nil)
	if len(p.Items) != 1 {
		t.Fatalf("want 1 item, got %d", len(p.Items))
	}
	if p.Items[0].DestRel == "x.jpg" {
		t.Fatal("collision was routed onto the existing name")
	}
	if disambiguate("x.jpg", sum(a)) == disambiguate("x.jpg", sum(b)) {
		t.Fatal("disambiguation is not content-dependent")
	}
}

// ---------------------------------------------------------------------------
// Hash mismatch
// ---------------------------------------------------------------------------

func TestCopyVerifiedRejectsHashMismatch(t *testing.T) {
	dir := t.TempDir()
	srcFile := writeFile(t, dir, "in.bin", filler(1, 5000))
	dstFile := filepath.Join(dir, "out", "in.bin")

	_, got, err := copyVerified(srcFile, dstFile, strings.Repeat("0", 64), time.Time{})
	if err == nil {
		t.Fatal("expected an error for a mismatched expected hash")
	}
	if !errors.Is(err, errHashMismatch) {
		t.Fatalf("error = %v, want errHashMismatch", err)
	}
	if got == "" {
		t.Error("the hash actually computed should be reported")
	}
	if _, err := os.Stat(dstFile); !os.IsNotExist(err) {
		t.Error("a mismatched copy must not be renamed into place")
	}
	if _, err := os.Stat(dstFile + ".part"); !os.IsNotExist(err) {
		t.Error("the partial file must be removed on mismatch")
	}
}

func TestCopyVerifiedAcceptsMatchingHash(t *testing.T) {
	dir := t.TempDir()
	data := filler(2, 5000)
	srcFile := writeFile(t, dir, "in.bin", data)
	dstFile := filepath.Join(dir, "out", "in.bin")

	n, got, err := copyVerified(srcFile, dstFile, sum(data), time.Time{})
	if err != nil {
		t.Fatal(err)
	}
	if n != int64(len(data)) || got != sum(data) {
		t.Fatalf("n=%d hash=%s, want %d / %s", n, got, len(data), sum(data))
	}
	out, err := os.ReadFile(dstFile)
	if err != nil || !bytes.Equal(out, data) {
		t.Fatal("destination is not byte-identical")
	}
}

func TestTransferRecordsAFailureAndKeepsGoing(t *testing.T) {
	// A file that changes between the inventory pass and the copy pass is the
	// real-world shape of a hash mismatch. We reproduce it by running the plan
	// against an inventory whose hash for one item is deliberately stale.
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))
	writeFile(t, src, "b.jpg", jpegBytes(2))
	writeFile(t, src, "c.jpg", jpegBytes(3))

	srcInv, err := scanTree(src)
	if err != nil {
		t.Fatal(err)
	}
	for i := range srcInv.Items {
		if srcInv.Items[i].Rel == "b.jpg" {
			srcInv.Items[i].SHA256 = strings.Repeat("a", 64) // stale hash
		}
	}
	dstInv, err := scanTree(dst)
	if err != nil {
		t.Fatal(err)
	}
	p := buildPlan(srcInv, dstInv, nil)

	rep, err := runTransfer(p, src, dst, ledger, 0, true)
	if err != nil {
		t.Fatal(err)
	}
	if rep.Failed != 1 {
		t.Errorf("failed = %d, want 1", rep.Failed)
	}
	if rep.Transferred != 2 {
		t.Errorf("transferred = %d, want 2 (the run must continue past a failure)", rep.Transferred)
	}
	if _, err := os.Stat(filepath.Join(dst, "b.jpg")); !os.IsNotExist(err) {
		t.Error("the mismatching file must not be present at the destination")
	}
	for _, rel := range []string{"a.jpg", "c.jpg"} {
		if _, err := os.Stat(filepath.Join(dst, rel)); err != nil {
			t.Errorf("%s should have transferred: %v", rel, err)
		}
	}
	entries, err := loadLedger(ledger)
	if err != nil {
		t.Fatal(err)
	}
	var failures int
	for _, e := range entries {
		if e.Status == statusFailed {
			failures++
			if e.Rel != "b.jpg" {
				t.Errorf("wrong item recorded as failed: %s", e.Rel)
			}
		}
	}
	if failures != 1 {
		t.Errorf("ledger records %d failures, want 1", failures)
	}
}

func TestTransferExitsTwoOnFailure(t *testing.T) {
	// Exit code 2 means "ran to completion, but something failed".
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))
	writeFile(t, dst, "a.jpg", jpegBytes(9))
	// Occupy the disambiguated name with a DIRECTORY so the copy cannot land.
	alt := disambiguate("a.jpg", sum(jpegBytes(1)))
	if err := os.MkdirAll(filepath.Join(dst, alt), 0o755); err != nil {
		t.Fatal(err)
	}
	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply")
	if r.code != 2 {
		t.Fatalf("exit = %d, want 2\n%s%s", r.code, r.stdout, r.stderr)
	}
	if !strings.Contains(r.stdout, "FAILURES") {
		t.Errorf("failures were not reported:\n%s", r.stdout)
	}
}

// ---------------------------------------------------------------------------
// Interrupt and resume
// ---------------------------------------------------------------------------

func TestInterruptedTransferResumesWithoutDuplicateWork(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")

	const nFiles = 60
	const fileSize = 512 * 1024
	want := make(map[string][]byte, nFiles)
	for i := 0; i < nFiles; i++ {
		rel := fmt.Sprintf("DCIM/Camera/IMG_%04d.JPG", i)
		data := append(jpegBytes(byte(i)), filler(byte(i+7), fileSize)...)
		writeFile(t, src, rel, data)
		want[rel] = data
	}
	srcBefore := treeHashes(t, src)

	// Start a real transfer and kill it once the ledger proves it is part-way.
	cmd := exec.Command(binPath, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply")
	cmd.Stdout, cmd.Stderr = nil, nil
	if err := cmd.Start(); err != nil {
		t.Fatal(err)
	}
	killedAt := -1
	deadline := time.Now().Add(60 * time.Second)
	for time.Now().Before(deadline) {
		n := countLines(ledger)
		if n >= 8 {
			if err := cmd.Process.Kill(); err != nil {
				t.Fatal(err)
			}
			killedAt = n
			break
		}
		time.Sleep(time.Millisecond)
	}
	_ = cmd.Wait()
	if killedAt < 0 {
		t.Fatalf("the transfer never reached 8 ledger entries; cannot test the interrupt path")
	}
	if killedAt >= nFiles {
		t.Fatalf("the transfer completed before it could be interrupted (%d entries)", killedAt)
	}

	afterKill := countLines(ledger)
	if afterKill >= nFiles {
		t.Fatalf("the transfer finished despite the kill (%d entries)", afterKill)
	}
	transferredBeforeKill := len(mustLoadVerified(t, ledger))
	if transferredBeforeKill == 0 {
		t.Fatal("nothing was recorded as verified before the kill")
	}

	// Resume.
	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply", "--json")
	if r.code != 0 {
		t.Fatalf("resume exited %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	var rep TransferReport
	if err := json.Unmarshal([]byte(r.stdout), &rep); err != nil {
		t.Fatal(err)
	}

	if rep.Failed != 0 {
		t.Errorf("resume reported %d failures", rep.Failed)
	}
	if rep.Resumed != transferredBeforeKill {
		t.Errorf("resume skipped %d files, want %d (everything the ledger had verified)",
			rep.Resumed, transferredBeforeKill)
	}
	if rep.Resumed+rep.Transferred != nFiles {
		t.Errorf("resumed %d + transferred %d != %d files", rep.Resumed, rep.Transferred, nFiles)
	}
	if rep.Transferred >= nFiles {
		t.Errorf("resume re-did the whole job: transferred %d of %d", rep.Transferred, nFiles)
	}
	// No duplicate work, measured in bytes actually moved.
	totalBytes := int64(0)
	for _, d := range want {
		totalBytes += int64(len(d))
	}
	if rep.TransferBytes >= totalBytes {
		t.Errorf("resume moved %d bytes, the whole tree is %d - it should have moved less",
			rep.TransferBytes, totalBytes)
	}
	if rep.LedgerDrift != 0 {
		t.Errorf("resume reported %d drift, want 0", rep.LedgerDrift)
	}

	// The end state is complete and correct.
	for rel, data := range want {
		got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
		if err != nil {
			t.Fatalf("%s missing after resume: %v", rel, err)
		}
		if !bytes.Equal(got, data) {
			t.Errorf("%s is not byte-identical after resume", rel)
		}
	}
	filepath.WalkDir(dst, func(p string, d fs.DirEntry, err error) error {
		if err == nil && !d.IsDir() && strings.HasSuffix(p, ".part") {
			t.Errorf("leftover partial file after resume: %s", p)
		}
		return nil
	})
	if diff := diffTrees(srcBefore, treeHashes(t, src)); diff != "" {
		t.Errorf("source tree was modified: %s", diff)
	}

	// And verify agrees.
	r = run(t, "verify", "--ledger", ledger, "--json")
	if r.code != 0 {
		t.Fatalf("verify exited %d\n%s%s", r.code, r.stdout, r.stderr)
	}
	var vr VerifyReport
	if err := json.Unmarshal([]byte(r.stdout), &vr); err != nil {
		t.Fatal(err)
	}
	if vr.Drift != 0 || vr.OK != nFiles {
		t.Errorf("verify: ok=%d drift=%d, want ok=%d drift=0", vr.OK, vr.Drift, nFiles)
	}
}

func countLines(path string) int {
	b, err := os.ReadFile(path)
	if err != nil {
		return 0
	}
	n := 0
	for _, line := range strings.Split(string(b), "\n") {
		if strings.TrimSpace(line) != "" {
			n++
		}
	}
	return n
}

func mustLoadVerified(t *testing.T, path string) []LedgerEntry {
	t.Helper()
	entries, err := loadLedger(path)
	if err != nil {
		t.Fatal(err)
	}
	var out []LedgerEntry
	for _, e := range entries {
		if e.Status == statusVerified {
			out = append(out, e)
		}
	}
	return out
}

// ---------------------------------------------------------------------------
// Corrupted destination on resume
// ---------------------------------------------------------------------------

func TestResumeDetectsCorruptedDestination(t *testing.T) {
	good := jpegBytes(1)

	cases := []struct {
		name       string
		corrupt    func(t *testing.T, path string)
		wantVerify string
	}{
		{"content changed in place", func(t *testing.T, path string) {
			bad := append([]byte(nil), good...)
			bad[len(bad)-1] ^= 0xFF
			if err := os.WriteFile(path, bad, 0o644); err != nil {
				t.Fatal(err)
			}
		}, vChanged},
		{"file truncated", func(t *testing.T, path string) {
			if err := os.WriteFile(path, good[:10], 0o644); err != nil {
				t.Fatal(err)
			}
		}, vResized},
		{"file deleted", func(t *testing.T, path string) {
			if err := os.Remove(path); err != nil {
				t.Fatal(err)
			}
		}, vMissing},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			src, dst := t.TempDir(), t.TempDir()
			ledger := filepath.Join(t.TempDir(), "l.jsonl")
			writeFile(t, src, "a.jpg", good)
			writeFile(t, src, "b.jpg", jpegBytes(2))
			writeFile(t, src, "c.jpg", jpegBytes(3))

			if r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply"); r.code != 0 {
				t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
			}
			c.corrupt(t, filepath.Join(dst, "a.jpg"))

			// verify must see it.
			r := run(t, "verify", "--ledger", ledger, "--json")
			if r.code != 2 {
				t.Fatalf("verify exit = %d, want 2\n%s%s", r.code, r.stdout, r.stderr)
			}
			var vr VerifyReport
			if err := json.Unmarshal([]byte(r.stdout), &vr); err != nil {
				t.Fatal(err)
			}
			if vr.Drift != 1 {
				t.Errorf("verify drift = %d, want 1", vr.Drift)
			}
			var sawResult string
			for _, it := range vr.Items {
				if it.DestRel == "a.jpg" {
					sawResult = it.Result
				}
			}
			if sawResult != c.wantVerify {
				t.Errorf("verify result for a.jpg = %q, want %q", sawResult, c.wantVerify)
			}

			// A resume must not trust the ledger: it re-hashes and repairs.
			r = run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply", "--json")
			if r.code != 0 {
				t.Fatalf("resume exit %d\n%s%s", r.code, r.stdout, r.stderr)
			}
			var rep TransferReport
			if err := json.Unmarshal([]byte(r.stdout), &rep); err != nil {
				t.Fatal(err)
			}
			if rep.LedgerDrift != 1 {
				t.Errorf("ledger drift = %d, want 1", rep.LedgerDrift)
			}
			if rep.Transferred != 1 {
				t.Errorf("transferred = %d, want 1 (only the damaged file)", rep.Transferred)
			}
			if rep.Resumed != 2 {
				t.Errorf("resumed = %d, want 2 (the two intact files)", rep.Resumed)
			}
			got, err := os.ReadFile(filepath.Join(dst, "a.jpg"))
			if err != nil || !bytes.Equal(got, good) {
				t.Errorf("a.jpg was not repaired")
			}
			r = run(t, "verify", "--ledger", ledger)
			if r.code != 0 {
				t.Errorf("verify after repair exit = %d, want 0\n%s", r.code, r.stdout)
			}
		})
	}
}

func TestResumeIgnoresATornFinalLedgerLine(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))
	writeFile(t, src, "b.jpg", jpegBytes(2))

	if r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply"); r.code != 0 {
		t.Fatalf("exit %d\n%s", r.code, r.stderr)
	}
	f, err := os.OpenFile(ledger, os.O_APPEND|os.O_WRONLY, 0o644)
	if err != nil {
		t.Fatal(err)
	}
	f.WriteString(`{"ts":"2026-08-11T00:00:00Z","rel":"c.jp`) // torn write
	f.Close()

	entries, err := loadLedger(ledger)
	if err != nil {
		t.Fatalf("a torn final line must not make the ledger unreadable: %v", err)
	}
	if len(entries) != 2 {
		t.Errorf("loaded %d entries, want 2", len(entries))
	}
	if r := run(t, "verify", "--ledger", ledger); r.code != 0 {
		t.Errorf("verify exit %d, want 0\n%s%s", r.code, r.stdout, r.stderr)
	}
}

// ---------------------------------------------------------------------------
// Source is read-only
// ---------------------------------------------------------------------------

func TestSourceIsNeverWritten(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "DCIM/a.jpg", jpegBytes(1))
	writeFile(t, src, "DCIM/dup.jpg", jpegBytes(1))
	writeFile(t, src, "Docs/x.pdf", pdfBytes(2))
	writeFile(t, dst, "DCIM/a.jpg", jpegBytes(9)) // force a collision too

	before := treeHashes(t, src)
	for _, args := range [][]string{
		{"inventory", "--src", src},
		{"plan", "--src", src, "--dst", dst},
		{"transfer", "--src", src, "--dst", dst, "--ledger", ledger},
		{"transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply"},
		{"verify", "--ledger", ledger},
	} {
		run(t, args...)
		if diff := diffTrees(before, treeHashes(t, src)); diff != "" {
			t.Fatalf("%v modified the source: %s", args, diff)
		}
	}
}

func TestOverlappingRootsAreRefused(t *testing.T) {
	root := t.TempDir()
	inner := filepath.Join(root, "inner")
	if err := os.MkdirAll(inner, 0o755); err != nil {
		t.Fatal(err)
	}
	cases := [][]string{
		{"plan", "--src", root, "--dst", inner},
		{"plan", "--src", inner, "--dst", root},
		{"plan", "--src", root, "--dst", root},
	}
	for _, args := range cases {
		r := run(t, args...)
		if r.code == 0 {
			t.Errorf("%v was accepted; overlapping roots must be refused", args)
		}
		if !strings.Contains(r.stderr, "overlap") {
			t.Errorf("%v: stderr does not explain the overlap:\n%s", args, r.stderr)
		}
	}
}

func TestLedgerInsideSourceIsRefused(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	writeFile(t, src, "a.jpg", jpegBytes(1))
	r := run(t, "transfer", "--src", src, "--dst", dst, "--ledger", filepath.Join(src, "l.jsonl"), "--apply")
	if r.code != 1 {
		t.Errorf("exit = %d, want 1", r.code)
	}
	if !strings.Contains(r.stderr, "source is never written") {
		t.Errorf("stderr does not explain why:\n%s", r.stderr)
	}
}

// ---------------------------------------------------------------------------
// Estimate
// ---------------------------------------------------------------------------

func TestEstimateIsMeasuredAndProbeIsRemoved(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	writeFile(t, src, "big.bin", filler(1, 2<<20))

	inv, err := scanTree(src)
	if err != nil {
		t.Fatal(err)
	}
	before := treeHashes(t, dst)
	e := estimateFor(inv.Bytes, inv, dst)

	if e.ReadRate <= 0 {
		t.Error("read rate was not measured")
	}
	if e.WriteRate <= 0 {
		t.Errorf("write rate was not measured: %s", e.WriteNote)
	}
	if e.Effective > e.ReadRate || e.Effective > e.WriteRate {
		t.Errorf("effective rate %v must not exceed either measured rate (%v, %v)",
			e.Effective, e.ReadRate, e.WriteRate)
	}
	if e.Seconds <= 0 {
		t.Error("no estimate produced")
	}
	if e.ReadSample != inv.Bytes {
		t.Errorf("read sample = %d, want %d", e.ReadSample, inv.Bytes)
	}
	if diff := diffTrees(before, treeHashes(t, dst)); diff != "" {
		t.Errorf("the throughput probe left something behind: %s", diff)
	}
}

// ---------------------------------------------------------------------------
// CLI contract
// ---------------------------------------------------------------------------

func TestUsageAndExitCodes(t *testing.T) {
	cases := []struct {
		name       string
		args       []string
		code       int
		wantStdout bool
		wantStderr bool
	}{
		{"help subcommand", []string{"help"}, 0, true, false},
		{"-h", []string{"-h"}, 0, true, false},
		{"--help", []string{"--help"}, 0, true, false},
		{"help after a subcommand", []string{"plan", "--help"}, 0, true, false},
		{"help after positional args", []string{"plan", "/tmp", "-h"}, 0, true, false},
		{"no arguments", nil, 1, false, true},
		{"unknown command", []string{"frobnicate"}, 1, false, true},
		{"plan without dst", []string{"plan", "--src", "/tmp"}, 1, false, true},
		{"transfer without ledger", []string{"transfer", "--src", "/tmp", "--dst", "/tmp/x"}, 1, false, true},
		{"verify without ledger", []string{"verify"}, 1, false, true},
		{"inventory without src", []string{"inventory"}, 1, false, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			r := run(t, c.args...)
			if r.code != c.code {
				t.Errorf("exit = %d, want %d\nstdout:%s\nstderr:%s", r.code, c.code, r.stdout, r.stderr)
			}
			if c.wantStdout {
				if !strings.Contains(r.stdout, "USAGE") {
					t.Errorf("usage did not go to stdout:\n%s", r.stdout)
				}
				if r.stderr != "" {
					t.Errorf("stderr should be empty on explicit help:\n%s", r.stderr)
				}
			}
			if c.wantStderr {
				if !strings.Contains(r.stderr, "USAGE") {
					t.Errorf("usage did not go to stderr:\n%s", r.stderr)
				}
				if r.stdout != "" {
					t.Errorf("stdout should be empty on a bad invocation:\n%s", r.stdout)
				}
			}
		})
	}
}

func TestFlagsMayFollowPositionalArguments(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))

	cases := [][]string{
		{"inventory", src, "--json"},
		{"inventory", "--json", src},
		{"plan", src, dst, "--json"},
		{"plan", "--json", src, dst},
		{"plan", src, "--dst", dst, "--json"},
		{"transfer", src, dst, "--ledger", ledger, "--json"},
		{"transfer", src, dst, "--json", "-l", ledger},
	}
	for _, args := range cases {
		t.Run(strings.Join(args, " "), func(t *testing.T) {
			r := run(t, args...)
			if r.code != 0 {
				t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
			}
			var v map[string]any
			if err := json.Unmarshal([]byte(r.stdout), &v); err != nil {
				t.Fatalf("--json did not produce JSON: %v\n%s", err, r.stdout)
			}
		})
	}
}

func TestJSONOnEveryReportingCommand(t *testing.T) {
	src, dst := t.TempDir(), t.TempDir()
	ledger := filepath.Join(t.TempDir(), "l.jsonl")
	writeFile(t, src, "a.jpg", jpegBytes(1))
	run(t, "transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--apply")

	cases := []struct {
		args []string
		key  string
	}{
		{[]string{"inventory", "--src", src, "--json"}, "dedup_adjusted_bytes"},
		{[]string{"plan", "--src", src, "--dst", dst, "--json"}, "already_present_identical"},
		{[]string{"transfer", "--src", src, "--dst", dst, "--ledger", ledger, "--json"}, "dry_run"},
		{[]string{"verify", "--ledger", ledger, "--json"}, "bytes_verified"},
	}
	for _, c := range cases {
		t.Run(c.args[0], func(t *testing.T) {
			r := run(t, c.args...)
			if r.code != 0 {
				t.Fatalf("exit %d\n%s%s", r.code, r.stdout, r.stderr)
			}
			var v map[string]any
			if err := json.Unmarshal([]byte(r.stdout), &v); err != nil {
				t.Fatalf("not JSON: %v\n%s", err, r.stdout)
			}
			if _, ok := v[c.key]; !ok {
				t.Errorf("JSON is missing %q", c.key)
			}
		})
	}
}

func TestHumanBytes(t *testing.T) {
	cases := []struct {
		n    int64
		want string
	}{
		{0, "0 B"},
		{1023, "1023 B"},
		{1024, "1.0 KiB"},
		{1536, "1.5 KiB"},
		{1 << 20, "1.0 MiB"},
		{3 * (1 << 30), "3.0 GiB"},
	}
	for _, c := range cases {
		if got := humanBytes(c.n); got != c.want {
			t.Errorf("humanBytes(%d) = %q, want %q", c.n, got, c.want)
		}
	}
}

func TestDisambiguate(t *testing.T) {
	h := strings.Repeat("ab", 32)
	cases := []struct {
		rel  string
		want string
	}{
		{"IMG_0001.JPG", "IMG_0001" + collisionMarker + "abababab.JPG"},
		{"DCIM/Camera/a.b.mp4", "DCIM/Camera/a.b" + collisionMarker + "abababab.mp4"},
		{"notes", "notes" + collisionMarker + "abababab"},
		{"a/b/.hidden", "a/b/" + collisionMarker + "abababab.hidden"},
	}
	for _, c := range cases {
		if got := disambiguate(c.rel, h); got != c.want {
			t.Errorf("disambiguate(%q) = %q, want %q", c.rel, got, c.want)
		}
	}
}
