package main

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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

func meta(hash string) *FileMeta {
	return &FileMeta{SHA256: hash, Size: int64(len(hash))}
}

// fakeTree builds an in-memory scanned tree from path -> content-hash.
func fakeTree(root string, files map[string]string) *Tree {
	t := newTree(root)
	for p, h := range files {
		t.Files[p] = FileMeta{SHA256: h, Size: int64(len(h))}
		t.Bytes += int64(len(h))
	}
	return t
}

func fakeBase(files map[string]string) *Manifest {
	m := newManifest("/A", "/B")
	for p, h := range files {
		m.Files[p] = FileMeta{SHA256: h, Size: int64(len(h))}
	}
	return m
}

// fixture is a pair of real directory trees plus a baseline file.
type fixture struct {
	dir  string
	A    string
	B    string
	base string
}

func newFixture(t *testing.T) *fixture {
	t.Helper()
	d := t.TempDir()
	f := &fixture{
		dir:  d,
		A:    filepath.Join(d, "oldphone"),
		B:    filepath.Join(d, "newphone"),
		base: filepath.Join(d, "baseline.json"),
	}
	for _, p := range []string{f.A, f.B} {
		if err := os.MkdirAll(p, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	return f
}

func (f *fixture) root(side string) string {
	if side == "A" {
		return f.A
	}
	return f.B
}

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

func (f *fixture) writeBoth(t *testing.T, rel, content string) {
	t.Helper()
	f.write(t, "A", rel, content)
	f.write(t, "B", rel, content)
}

// userDeletes simulates the human deleting a photo on a phone. pocketsync
// itself never does this.
func (f *fixture) userDeletes(t *testing.T, side, rel string) {
	t.Helper()
	if err := os.Remove(filepath.Join(f.root(side), filepath.FromSlash(rel))); err != nil {
		t.Fatal(err)
	}
}

func (f *fixture) userRenames(t *testing.T, side, from, to string) {
	t.Helper()
	src := filepath.Join(f.root(side), filepath.FromSlash(from))
	dst := filepath.Join(f.root(side), filepath.FromSlash(to))
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Rename(src, dst); err != nil {
		t.Fatal(err)
	}
}

func (f *fixture) scan(t *testing.T, side string) *Tree {
	t.Helper()
	tr, err := scanTree(f.root(side), map[string]bool{f.base: true})
	if err != nil {
		t.Fatal(err)
	}
	return tr
}

// recordBaseline snapshots the current agreed state, exactly as the baseline
// subcommand does.
func (f *fixture) recordBaseline(t *testing.T) {
	t.Helper()
	old, err := loadManifest(f.base)
	if err != nil {
		if !errors.Is(err, errNoBaseline) {
			t.Fatal(err)
		}
		old = nil
	}
	m, _ := mergeBaseline(old, f.scan(t, "A"), f.scan(t, "B"))
	if _, err := writeManifest(f.base, m); err != nil {
		t.Fatal(err)
	}
}

func (f *fixture) plan(t *testing.T, policy string) *Plan {
	t.Helper()
	base, err := loadManifest(f.base)
	if err != nil {
		if !errors.Is(err, errNoBaseline) {
			t.Fatal(err)
		}
		base = newManifest(f.A, f.B)
	}
	p := buildPlan(f.scan(t, "A"), f.scan(t, "B"), base, policy)
	p.BaselinePath = f.base
	return p
}

// sync builds a plan, applies it and advances the baseline.
func (f *fixture) sync(t *testing.T, policy string) (*Plan, *ApplyResult) {
	t.Helper()
	p := f.plan(t, policy)
	res, err := applyPlan(p)
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Failed) != 0 {
		t.Fatalf("apply reported failures: %+v", res.Failed)
	}
	if _, err := advanceBaseline(f.base, f.A, f.B); err != nil {
		t.Fatal(err)
	}
	return p, res
}

// snapshot is every file in a tree as path -> sha256, for byte-identity checks.
func (f *fixture) snapshot(t *testing.T, side string) map[string]string {
	t.Helper()
	out := map[string]string{}
	root := f.root(side)
	err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(root, p)
		if err != nil {
			return err
		}
		s, _, err := hashFile(p)
		if err != nil {
			return err
		}
		out[filepath.ToSlash(rel)] = s
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return out
}

func (f *fixture) read(t *testing.T, side, rel string) string {
	t.Helper()
	b, err := os.ReadFile(filepath.Join(f.root(side), filepath.FromSlash(rel)))
	if err != nil {
		t.Fatal(err)
	}
	return string(b)
}

func (f *fixture) exists(side, rel string) bool {
	_, err := os.Stat(filepath.Join(f.root(side), filepath.FromSlash(rel)))
	return err == nil
}

func classOf(p *Plan, path string) Class {
	for _, r := range p.Records {
		if r.Path == path {
			return r.Class
		}
	}
	return Class("<absent>")
}

func opsFor(p *Plan, path string) []Op {
	var out []Op
	for _, o := range p.Ops {
		if o.DstPath == path || o.SrcPath == path {
			out = append(out, o)
		}
	}
	return out
}

// ---------------------------------------------------------------------------
// 1. The decision table, every cell
// ---------------------------------------------------------------------------

func TestClassifyDecisionTable(t *testing.T) {
	h1, h2, h3 := "hash-one", "hash-two", "hash-three"

	cases := []struct {
		name         string
		base, a, b   *FileMeta
		want         Class
		otherChanged bool
	}{
		// --- no baseline entry -------------------------------------------------
		{"absent everywhere", nil, nil, nil, ClassIdentical, false},
		{"added on A only", nil, meta(h1), nil, ClassNewA, false},
		{"added on B only", nil, nil, meta(h1), ClassNewB, false},
		{"added on both, same bytes", nil, meta(h1), meta(h1), ClassIdentical, false},
		{"added on both, different bytes", nil, meta(h1), meta(h2), ClassConflict, false},

		// --- baseline entry, present on one side --------------------------------
		{"deleted on B, A untouched", meta(h1), meta(h1), nil, ClassDeletedB, false},
		{"deleted on B, A edited it", meta(h1), meta(h2), nil, ClassDeletedB, true},
		{"deleted on A, B untouched", meta(h1), nil, meta(h1), ClassDeletedA, false},
		{"deleted on A, B edited it", meta(h1), nil, meta(h2), ClassDeletedA, true},
		{"deleted on both", meta(h1), nil, nil, ClassDeletedAll, false},

		// --- baseline entry, present on both sides ------------------------------
		{"untouched on both", meta(h1), meta(h1), meta(h1), ClassIdentical, false},
		{"same edit on both", meta(h1), meta(h2), meta(h2), ClassConverged, false},
		{"edited on B only", meta(h1), meta(h1), meta(h2), ClassChangedB, false},
		{"edited on A only", meta(h1), meta(h2), meta(h1), ClassChangedA, false},
		{"edited differently on both", meta(h1), meta(h2), meta(h3), ClassConflict, false},
	}

	seen := map[Class]bool{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, other := classify(tc.base, tc.a, tc.b)
			if got != tc.want {
				t.Errorf("classify() = %q, want %q", got, tc.want)
			}
			if other != tc.otherChanged {
				t.Errorf("otherChanged = %v, want %v", other, tc.otherChanged)
			}
		})
		seen[tc.want] = true
	}

	for _, c := range AllClasses {
		if !seen[c] {
			t.Errorf("decision table test never produced class %q", c)
		}
	}
}

// Swapping the two sides must mirror the verdict, cell for cell.
func TestClassifyIsSymmetric(t *testing.T) {
	hashes := []*FileMeta{nil, meta("h1"), meta("h2"), meta("h3")}
	for _, base := range hashes {
		for _, a := range hashes {
			for _, b := range hashes {
				got, gotOther := classify(base, a, b)
				mir, mirOther := classify(base, b, a)
				if want := mirrorClass(mir); got != want {
					t.Fatalf("classify(%v,%v,%v)=%q but mirrored classify=%q",
						show(base), show(a), show(b), got, want)
				}
				if gotOther != mirOther {
					t.Fatalf("otherChanged not symmetric for (%v,%v,%v)", show(base), show(a), show(b))
				}
			}
		}
	}
}

func show(m *FileMeta) string {
	if m == nil {
		return "-"
	}
	return m.SHA256
}

// ---------------------------------------------------------------------------
// 2. Every cell produces the right operations
// ---------------------------------------------------------------------------

func TestPlanOperationsForEveryClass(t *testing.T) {
	h1, h2, h3 := "h1", "h2", "h3"
	base := fakeBase(map[string]string{
		"del-b.jpg": h1, "del-a.jpg": h1, "del-both.jpg": h1,
		"same.jpg": h1, "conv.jpg": h1, "chg-a.jpg": h1, "chg-b.jpg": h1,
		"conflict.jpg": h1,
	})
	a := fakeTree("/A", map[string]string{
		"new-a.jpg": h2, "del-b.jpg": h1, "same.jpg": h1, "conv.jpg": h2,
		"chg-a.jpg": h2, "chg-b.jpg": h1, "conflict.jpg": h2,
	})
	b := fakeTree("/B", map[string]string{
		"new-b.jpg": h3, "del-a.jpg": h1, "same.jpg": h1, "conv.jpg": h2,
		"chg-a.jpg": h1, "chg-b.jpg": h2, "conflict.jpg": h3,
	})
	p := buildPlan(a, b, base, PolicyNone)

	wantClass := map[string]Class{
		"new-a.jpg":    ClassNewA,
		"new-b.jpg":    ClassNewB,
		"del-a.jpg":    ClassDeletedA,
		"del-b.jpg":    ClassDeletedB,
		"del-both.jpg": ClassDeletedAll,
		"same.jpg":     ClassIdentical,
		"conv.jpg":     ClassConverged,
		"chg-a.jpg":    ClassChangedA,
		"chg-b.jpg":    ClassChangedB,
		"conflict.jpg": ClassConflict,
	}
	for path, want := range wantClass {
		if got := classOf(p, path); got != want {
			t.Errorf("%s classified %q, want %q", path, got, want)
		}
	}

	type opWant struct{ dir, dst string }
	wantOps := map[string]opWant{
		"new-a.jpg": {"A->B", "new-a.jpg"},
		"new-b.jpg": {"B->A", "new-b.jpg"},
		"chg-a.jpg": {"A->B", "chg-a.jpg"},
		"chg-b.jpg": {"B->A", "chg-b.jpg"},
	}
	if len(p.Ops) != len(wantOps) {
		t.Fatalf("got %d operations, want %d: %+v", len(p.Ops), len(wantOps), p.Ops)
	}
	for _, o := range p.Ops {
		w, ok := wantOps[o.SrcPath]
		if !ok {
			t.Errorf("unexpected operation on %s", o.SrcPath)
			continue
		}
		if o.Direction() != w.dir || o.DstPath != w.dst {
			t.Errorf("%s: got %s -> %s, want %s -> %s", o.SrcPath, o.Direction(), o.DstPath, w.dir, w.dst)
		}
	}

	// Classes that must never produce an operation.
	for _, path := range []string{"del-a.jpg", "del-b.jpg", "del-both.jpg", "same.jpg", "conv.jpg", "conflict.jpg"} {
		if got := opsFor(p, path); len(got) != 0 {
			t.Errorf("%s produced operations %+v, want none", path, got)
		}
	}

	// changed-on-X must carry the baseline hash it expects to replace.
	for _, o := range p.Ops {
		if o.Reason == string(ClassChangedA) || o.Reason == string(ClassChangedB) {
			if o.ExpectDest != h1 {
				t.Errorf("%s: ExpectDest = %q, want the baseline hash %q", o.SrcPath, o.ExpectDest, h1)
			}
		}
	}
	if p.Counts.NeedDecision != 1 {
		t.Errorf("NeedDecision = %d, want 1", p.Counts.NeedDecision)
	}
}

// A deletion on one side never becomes an operation, whatever else happened.
func TestNoOperationEverTargetsADeletedPath(t *testing.T) {
	h1, h2 := "h1", "h2"
	cases := []struct {
		name  string
		base  map[string]string
		a     map[string]string
		b     map[string]string
		class Class
	}{
		{"deleted on A", map[string]string{"p.jpg": h1}, map[string]string{}, map[string]string{"p.jpg": h1}, ClassDeletedA},
		{"deleted on B", map[string]string{"p.jpg": h1}, map[string]string{"p.jpg": h1}, map[string]string{}, ClassDeletedB},
		{"deleted on A, edited on B", map[string]string{"p.jpg": h1}, map[string]string{}, map[string]string{"p.jpg": h2}, ClassDeletedA},
		{"deleted on B, edited on A", map[string]string{"p.jpg": h1}, map[string]string{"p.jpg": h2}, map[string]string{}, ClassDeletedB},
		{"deleted on both", map[string]string{"p.jpg": h1}, map[string]string{}, map[string]string{}, ClassDeletedAll},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			for _, policy := range []string{PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB} {
				p := buildPlan(fakeTree("/A", tc.a), fakeTree("/B", tc.b), fakeBase(tc.base), policy)
				if got := classOf(p, "p.jpg"); got != tc.class {
					t.Fatalf("policy %q: class = %q, want %q", policy, got, tc.class)
				}
				if len(p.Ops) != 0 {
					t.Fatalf("policy %q: deletion produced operations %+v", policy, p.Ops)
				}
				if len(p.Deletions) != 1 {
					t.Fatalf("policy %q: deletion not reported", policy)
				}
			}
		})
	}
}

// ---------------------------------------------------------------------------
// 3. Rename and relocation detection
// ---------------------------------------------------------------------------

func TestRenameDetection(t *testing.T) {
	t.Run("rename on A", func(t *testing.T) {
		f := newFixture(t)
		f.writeBoth(t, "DCIM/IMG_0001.JPG", "beach")
		f.writeBoth(t, "DCIM/IMG_0002.JPG", "cake")
		f.recordBaseline(t)
		f.userRenames(t, "A", "DCIM/IMG_0001.JPG", "DCIM/beach-day.jpg")

		p := f.plan(t, PolicyNone)
		if got := classOf(p, "DCIM/IMG_0001.JPG"); got != ClassDeletedA {
			t.Errorf("old name classified %q, want %q", got, ClassDeletedA)
		}
		if got := classOf(p, "DCIM/beach-day.jpg"); got != ClassNewA {
			t.Errorf("new name classified %q, want %q", got, ClassNewA)
		}
		if len(p.Renames) != 1 {
			t.Fatalf("got %d renames, want 1: %+v", len(p.Renames), p.Renames)
		}
		r := p.Renames[0]
		if r.Kind != "rename-on-A" || r.From != "DCIM/IMG_0001.JPG" || r.To != "DCIM/beach-day.jpg" {
			t.Errorf("rename = %+v", r)
		}
		if r.SHA256 != sum("beach") {
			t.Errorf("rename hash = %s, want %s", r.SHA256, sum("beach"))
		}
		if len(p.Ops) != 0 {
			t.Errorf("a rename produced copies %+v, want none", p.Ops)
		}
	})

	t.Run("rename on B", func(t *testing.T) {
		f := newFixture(t)
		f.writeBoth(t, "a.jpg", "one")
		f.recordBaseline(t)
		f.userRenames(t, "B", "a.jpg", "b.jpg")
		p := f.plan(t, PolicyNone)
		if len(p.Renames) != 1 || p.Renames[0].Kind != "rename-on-B" {
			t.Fatalf("renames = %+v", p.Renames)
		}
		if len(p.Ops) != 0 {
			t.Errorf("ops = %+v, want none", p.Ops)
		}
	})

	t.Run("relocated: same bytes, different name on each side, no baseline", func(t *testing.T) {
		f := newFixture(t)
		f.write(t, "A", "DCIM/IMG_9.JPG", "sunset")
		f.write(t, "B", "Photos/sunset.jpg", "sunset")
		p := f.plan(t, PolicyNone)
		if len(p.Renames) != 1 {
			t.Fatalf("renames = %+v", p.Renames)
		}
		r := p.Renames[0]
		if r.Kind != "relocated" || r.From != "DCIM/IMG_9.JPG" || r.To != "Photos/sunset.jpg" {
			t.Errorf("relocation = %+v", r)
		}
		if len(p.Ops) != 0 {
			t.Errorf("a relocation produced copies %+v, want none - both sides already hold the bytes", p.Ops)
		}
		if p.Counts.Relocated != 1 {
			t.Errorf("Relocated count = %d, want 1", p.Counts.Relocated)
		}
	})

	t.Run("a genuinely new file is not mistaken for a rename", func(t *testing.T) {
		f := newFixture(t)
		f.writeBoth(t, "a.jpg", "one")
		f.recordBaseline(t)
		f.write(t, "A", "b.jpg", "different bytes")
		p := f.plan(t, PolicyNone)
		if len(p.Renames) != 0 {
			t.Fatalf("renames = %+v, want none", p.Renames)
		}
		if len(p.Ops) != 1 || p.Ops[0].DstPath != "b.jpg" || p.Ops[0].Direction() != "A->B" {
			t.Fatalf("ops = %+v", p.Ops)
		}
	})
}

// ---------------------------------------------------------------------------
// 4. Symmetry: swapping A and B mirrors the plan exactly
// ---------------------------------------------------------------------------

func mirrorClass(c Class) Class {
	switch c {
	case ClassNewA:
		return ClassNewB
	case ClassNewB:
		return ClassNewA
	case ClassChangedA:
		return ClassChangedB
	case ClassChangedB:
		return ClassChangedA
	case ClassDeletedA:
		return ClassDeletedB
	case ClassDeletedB:
		return ClassDeletedA
	default:
		return c
	}
}

func mirrorSide(s string) string {
	switch s {
	case "A":
		return "B"
	case "B":
		return "A"
	default:
		return s
	}
}

// mirrorPath swaps the -a and -b suffix markers used by the conflict policies.
func mirrorPath(p string) string {
	const ph = "\x00SWAP\x00"
	p = strings.ReplaceAll(p, "."+appName+"-a", ph)
	p = strings.ReplaceAll(p, "."+appName+"-b", "."+appName+"-a")
	return strings.ReplaceAll(p, ph, "."+appName+"-b")
}

func mirrorPolicy(p string) string {
	switch p {
	case PolicyPreferA:
		return PolicyPreferB
	case PolicyPreferB:
		return PolicyPreferA
	default:
		return p
	}
}

func mirrorOp(o Op) Op {
	return Op{
		Kind:    o.Kind,
		Phase:   o.Phase,
		SrcSide: mirrorSide(o.SrcSide), SrcPath: mirrorPath(o.SrcPath),
		DstSide: mirrorSide(o.DstSide), DstPath: mirrorPath(o.DstPath),
		SHA256: o.SHA256, Size: o.Size, ExpectDest: o.ExpectDest,
		Reason: "", // reason text names a side; compared separately
	}
}

func mirrorRename(r Rename) Rename {
	out := Rename{Kind: r.Kind, From: r.From, To: r.To, SHA256: r.SHA256, Size: r.Size}
	switch r.Kind {
	case "rename-on-A":
		out.Kind = "rename-on-B"
	case "rename-on-B":
		out.Kind = "rename-on-A"
	case "relocated":
		out.From, out.To = r.To, r.From
	}
	return out
}

func mirrorCounts(c Counts) Counts {
	return Counts{
		Paths: c.Paths, Identical: c.Identical,
		NewA: c.NewB, NewB: c.NewA,
		ChangedA: c.ChangedB, ChangedB: c.ChangedA,
		Converged: c.Converged, Conflicts: c.Conflicts,
		DeletedA: c.DeletedB, DeletedB: c.DeletedA, DeletedBoth: c.DeletedBoth,
		RenamesA: c.RenamesB, RenamesB: c.RenamesA, Relocated: c.Relocated,
		CopyOps: c.CopyOps, PreserveOps: c.PreserveOps,
		CopyAToB: c.CopyBToA, CopyBToA: c.CopyAToB,
		BytesAToB: c.BytesBToA, BytesBToA: c.BytesAToB,
		NeedDecision: c.NeedDecision,
	}
}

func opKeys(ops []Op) []Op {
	out := make([]Op, 0, len(ops))
	for _, o := range ops {
		o.Reason = ""
		out = append(out, o)
	}
	sortOps(out)
	return out
}

// divergentFixture exercises every interesting shape at once.
func divergentFixture(t *testing.T) *fixture {
	f := newFixture(t)
	f.writeBoth(t, "DCIM/IMG_0001.JPG", "beach")
	f.writeBoth(t, "DCIM/IMG_0002.JPG", "cake")
	f.writeBoth(t, "DCIM/IMG_0003.JPG", "dog")
	f.writeBoth(t, "DCIM/IMG_0004.JPG", "hill")
	f.writeBoth(t, "DCIM/IMG_0005.JPG", "kite")
	f.recordBaseline(t)

	f.write(t, "A", "DCIM/IMG_0100.JPG", "new on the old phone")
	f.write(t, "B", "DCIM/IMG_0200.JPG", "new on the new phone")
	f.write(t, "A", "DCIM/IMG_0002.JPG", "cake, cropped on A")           // changed-on-A
	f.write(t, "B", "DCIM/IMG_0003.JPG", "dog, cropped on B")            // changed-on-B
	f.write(t, "A", "DCIM/IMG_0004.JPG", "hill, edited on A")            // conflict
	f.write(t, "B", "DCIM/IMG_0004.JPG", "hill, edited on B")            // conflict
	f.userRenames(t, "A", "DCIM/IMG_0005.JPG", "DCIM/kite-festival.jpg") // rename on A
	f.userDeletes(t, "B", "DCIM/IMG_0001.JPG")                           // deletion on B
	return f
}

func TestPlanIsSymmetric(t *testing.T) {
	policies := []string{PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB}
	for _, policy := range policies {
		t.Run("policy="+policyLabel(policy), func(t *testing.T) {
			f := divergentFixture(t)

			base, err := loadManifest(f.base)
			if err != nil {
				t.Fatal(err)
			}
			ta, tb := f.scan(t, "A"), f.scan(t, "B")

			forward := buildPlan(ta, tb, base, policy)
			swapped := buildPlan(tb, ta, base, mirrorPolicy(policy))

			// Classification mirrors.
			if len(forward.Records) != len(swapped.Records) {
				t.Fatalf("record counts differ: %d vs %d", len(forward.Records), len(swapped.Records))
			}
			for i := range forward.Records {
				fwd, swp := forward.Records[i], swapped.Records[i]
				if fwd.Path != swp.Path {
					t.Fatalf("record %d path %q vs %q", i, fwd.Path, swp.Path)
				}
				if fwd.Class != mirrorClass(swp.Class) {
					t.Errorf("%s: forward %q, swapped %q (mirrors to %q)",
						fwd.Path, fwd.Class, swp.Class, mirrorClass(swp.Class))
				}
				if fwd.OtherChanged != swp.OtherChanged {
					t.Errorf("%s: OtherChanged %v vs %v", fwd.Path, fwd.OtherChanged, swp.OtherChanged)
				}
			}

			// Operations mirror.
			gotFwd := opKeys(forward.Ops)
			gotSwap := make([]Op, 0, len(swapped.Ops))
			for _, o := range swapped.Ops {
				gotSwap = append(gotSwap, mirrorOp(o))
			}
			sortOps(gotSwap)
			if !reflect.DeepEqual(gotFwd, gotSwap) {
				t.Errorf("operations are not mirror images:\nforward: %+v\nswapped: %+v", gotFwd, gotSwap)
			}

			// Renames mirror.
			mirrored := make([]Rename, 0, len(swapped.Renames))
			for _, r := range swapped.Renames {
				m := mirrorRename(r)
				m.Comment = ""
				mirrored = append(mirrored, m)
			}
			plainFwd := make([]Rename, 0, len(forward.Renames))
			for _, r := range forward.Renames {
				r.Comment = ""
				plainFwd = append(plainFwd, r)
			}
			sortRenames(plainFwd)
			sortRenames(mirrored)
			if !reflect.DeepEqual(plainFwd, mirrored) {
				t.Errorf("renames are not mirror images:\nforward: %+v\nswapped: %+v", plainFwd, mirrored)
			}

			// Counts mirror.
			if got, want := forward.Counts, mirrorCounts(swapped.Counts); got != want {
				t.Errorf("counts are not mirror images:\nforward: %+v\nmirrored swapped: %+v", got, want)
			}

			// Conflicts mirror.
			if len(forward.Conflicts) != len(swapped.Conflicts) {
				t.Fatalf("conflict counts differ")
			}
			for i := range forward.Conflicts {
				fc, sc := forward.Conflicts[i], swapped.Conflicts[i]
				if fc.Path != sc.Path || fc.ASHA256 != sc.BSHA256 || fc.BSHA256 != sc.ASHA256 {
					t.Errorf("conflict %d not mirrored: %+v vs %+v", i, fc, sc)
				}
			}

			// Deletions mirror.
			if len(forward.Deletions) != len(swapped.Deletions) {
				t.Fatalf("deletion counts differ")
			}
			for i := range forward.Deletions {
				fd, sd := forward.Deletions[i], swapped.Deletions[i]
				if fd.Path != sd.Path || fd.MissingOn != mirrorSide(sd.MissingOn) {
					t.Errorf("deletion %d not mirrored: %+v vs %+v", i, fd, sd)
				}
			}
		})
	}
}

func sortRenames(rs []Rename) {
	sort.SliceStable(rs, func(i, j int) bool {
		if rs[i].From != rs[j].From {
			return rs[i].From < rs[j].From
		}
		return rs[i].To < rs[j].To
	})
}

// ---------------------------------------------------------------------------
// 5. A dry run changes nothing
// ---------------------------------------------------------------------------

func TestDryRunLeavesBothTreesByteIdentical(t *testing.T) {
	for _, policy := range []string{PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB} {
		t.Run("policy="+policyLabel(policy), func(t *testing.T) {
			f := divergentFixture(t)
			beforeA, beforeB := f.snapshot(t, "A"), f.snapshot(t, "B")
			baselineBefore, err := os.ReadFile(f.base)
			if err != nil {
				t.Fatal(err)
			}

			p := f.plan(t, policy)
			if len(p.Ops) == 0 {
				t.Fatal("fixture produced no operations; the test would prove nothing")
			}
			// Render every report, since printing must not write either.
			printPlan(io_Discard{}, p, false)
			printStatus(io_Discard{}, p)

			if got := f.snapshot(t, "A"); !reflect.DeepEqual(beforeA, got) {
				t.Errorf("side A changed during a dry run:\nbefore %v\nafter  %v", beforeA, got)
			}
			if got := f.snapshot(t, "B"); !reflect.DeepEqual(beforeB, got) {
				t.Errorf("side B changed during a dry run:\nbefore %v\nafter  %v", beforeB, got)
			}
			after, err := os.ReadFile(f.base)
			if err != nil {
				t.Fatal(err)
			}
			if string(after) != string(baselineBefore) {
				t.Error("the baseline changed during a dry run")
			}
		})
	}
}

// io_Discard is a writer that keeps the report code honest without printing.
type io_Discard struct{}

func (io_Discard) Write(b []byte) (int, error) { return len(b), nil }

// ---------------------------------------------------------------------------
// 6. Apply: correctness, idempotence, and no propagated deletions
// ---------------------------------------------------------------------------

func TestSyncMergesBothDirections(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "DCIM/IMG_0001.JPG", "shared")
	f.recordBaseline(t)
	f.write(t, "A", "DCIM/IMG_0100.JPG", "only on the old phone")
	f.write(t, "B", "DCIM/IMG_0200.JPG", "only on the new phone")

	p, res := f.sync(t, PolicyNone)
	if res.Done != 2 {
		t.Fatalf("performed %d operations, want 2 (%+v)", res.Done, res.Results)
	}
	if p.Counts.CopyAToB != 1 || p.Counts.CopyBToA != 1 {
		t.Fatalf("counts = %+v", p.Counts)
	}
	if got := f.read(t, "B", "DCIM/IMG_0100.JPG"); got != "only on the old phone" {
		t.Errorf("A->B copy has content %q", got)
	}
	if got := f.read(t, "A", "DCIM/IMG_0200.JPG"); got != "only on the new phone" {
		t.Errorf("B->A copy has content %q", got)
	}
	if !reflect.DeepEqual(f.snapshot(t, "A"), f.snapshot(t, "B")) {
		t.Error("the two sides did not converge")
	}
}

func TestSyncIsIdempotent(t *testing.T) {
	for _, policy := range []string{PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB} {
		t.Run("policy="+policyLabel(policy), func(t *testing.T) {
			f := divergentFixture(t)
			_, res1 := f.sync(t, policy)
			if res1.Done == 0 {
				t.Fatal("first sync did nothing; the test would prove nothing")
			}
			afterA, afterB := f.snapshot(t, "A"), f.snapshot(t, "B")

			p2, res2 := f.sync(t, policy)
			if res2.Done != 0 {
				t.Errorf("second sync performed %d operations, want 0: %+v", res2.Done, res2.Results)
			}
			if res2.BytesWritten != 0 {
				t.Errorf("second sync wrote %d bytes, want 0", res2.BytesWritten)
			}
			for _, o := range p2.Ops {
				// Any op still planned must be a no-op against the current disk.
				t.Logf("second run still plans %s %s (harmless, verified as already present)", o.Direction(), o.DstPath)
			}
			if got := f.snapshot(t, "A"); !reflect.DeepEqual(afterA, got) {
				t.Errorf("second sync changed side A")
			}
			if got := f.snapshot(t, "B"); !reflect.DeepEqual(afterB, got) {
				t.Errorf("second sync changed side B")
			}

			// A third pass, for good measure.
			_, res3 := f.sync(t, policy)
			if res3.Done != 0 {
				t.Errorf("third sync performed %d operations, want 0", res3.Done)
			}
		})
	}
}

func TestDeletionIsNeverPropagated(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "DCIM/IMG_0001.JPG", "wedding")
	f.writeBoth(t, "DCIM/IMG_0002.JPG", "birthday")
	f.recordBaseline(t)

	// The user deletes one photo on the new phone, and takes a new one there.
	f.userDeletes(t, "B", "DCIM/IMG_0001.JPG")
	f.write(t, "B", "DCIM/IMG_0300.JPG", "brand new")

	p, res := f.sync(t, PolicyNone)
	if got := classOf(p, "DCIM/IMG_0001.JPG"); got != ClassDeletedB {
		t.Fatalf("class = %q, want %q", got, ClassDeletedB)
	}
	for _, o := range p.Ops {
		if o.SrcPath == "DCIM/IMG_0001.JPG" || o.DstPath == "DCIM/IMG_0001.JPG" {
			t.Errorf("the deleted path produced an operation: %+v", o)
		}
	}
	if res.Done != 1 {
		t.Errorf("performed %d operations, want 1 (only the new photo)", res.Done)
	}
	// The surviving copy is untouched...
	if got := f.read(t, "A", "DCIM/IMG_0001.JPG"); got != "wedding" {
		t.Errorf("side A's surviving copy = %q", got)
	}
	// ...and the deletion was not undone on the side that made it.
	if f.exists("B", "DCIM/IMG_0001.JPG") {
		t.Error("the deleted file came back on side B")
	}
	if len(p.Deletions) != 1 || p.Deletions[0].MissingOn != "B" {
		t.Errorf("deletions = %+v", p.Deletions)
	}

	// And it stays that way on every later run.
	p2, res2 := f.sync(t, PolicyNone)
	if res2.Done != 0 {
		t.Errorf("second sync performed %d operations, want 0", res2.Done)
	}
	if got := classOf(p2, "DCIM/IMG_0001.JPG"); got != ClassDeletedB {
		t.Errorf("after the baseline advanced, class = %q, want %q (a deletion must not turn back into a new file)", got, ClassDeletedB)
	}
	if f.exists("B", "DCIM/IMG_0001.JPG") {
		t.Error("the deleted file came back on the second run")
	}
	if got := f.read(t, "A", "DCIM/IMG_0001.JPG"); got != "wedding" {
		t.Error("side A's copy was disturbed on the second run")
	}
}

func TestChangedOnOneSidePropagates(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "note.txt", "original")
	f.recordBaseline(t)
	f.write(t, "A", "note.txt", "edited on A")

	p, res := f.sync(t, PolicyNone)
	if got := classOf(p, "note.txt"); got != ClassChangedA {
		t.Fatalf("class = %q, want %q", got, ClassChangedA)
	}
	if res.Done != 1 {
		t.Fatalf("performed %d operations, want 1", res.Done)
	}
	if got := f.read(t, "B", "note.txt"); got != "edited on A" {
		t.Errorf("side B = %q, want the edit", got)
	}
}

// A changed-on-A copy refuses to overwrite a destination that is not what the
// plan said it was.
func TestApplyRefusesUnexpectedDestination(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "note.txt", "original")
	f.recordBaseline(t)
	f.write(t, "A", "note.txt", "edited on A")

	p := f.plan(t, PolicyNone)
	if len(p.Ops) != 1 {
		t.Fatalf("ops = %+v", p.Ops)
	}
	// Someone edits side B after the plan was made.
	f.write(t, "B", "note.txt", "edited on B in the meantime")

	res, err := applyPlan(p)
	if err != nil {
		t.Fatal(err)
	}
	if res.Done != 0 {
		t.Errorf("performed %d operations, want 0", res.Done)
	}
	if res.Results[0].Status != StatusDestUnexpected {
		t.Errorf("status = %q, want %q", res.Results[0].Status, StatusDestUnexpected)
	}
	if got := f.read(t, "B", "note.txt"); got != "edited on B in the meantime" {
		t.Errorf("side B was overwritten anyway: %q", got)
	}
}

// ---------------------------------------------------------------------------
// 7. Conflict policies
// ---------------------------------------------------------------------------

func conflictFixture(t *testing.T) *fixture {
	f := newFixture(t)
	f.writeBoth(t, "DCIM/IMG_0004.JPG", "hill")
	f.recordBaseline(t)
	f.write(t, "A", "DCIM/IMG_0004.JPG", "hill, edited on the old phone")
	f.write(t, "B", "DCIM/IMG_0004.JPG", "hill, edited on the new phone")
	return f
}

func TestConflictPolicies(t *testing.T) {
	const (
		aVersion = "hill, edited on the old phone"
		bVersion = "hill, edited on the new phone"
		path     = "DCIM/IMG_0004.JPG"
		keptA    = "DCIM/IMG_0004.pocketsync-a.JPG"
		keptB    = "DCIM/IMG_0004.pocketsync-b.JPG"
	)

	t.Run("no policy reports and skips", func(t *testing.T) {
		f := conflictFixture(t)
		p, res := f.sync(t, PolicyNone)
		if got := classOf(p, path); got != ClassConflict {
			t.Fatalf("class = %q", got)
		}
		if len(p.Ops) != 0 || res.Done != 0 {
			t.Errorf("a policy-less conflict wrote something: ops %+v", p.Ops)
		}
		if len(p.Conflicts) != 1 {
			t.Fatalf("conflicts = %+v", p.Conflicts)
		}
		if !strings.Contains(p.Conflicts[0].Resolution, "skipped") {
			t.Errorf("resolution = %q", p.Conflicts[0].Resolution)
		}
		if f.read(t, "A", path) != aVersion || f.read(t, "B", path) != bVersion {
			t.Error("a version was altered despite no policy")
		}
	})

	t.Run("keep-both writes both versions to both sides", func(t *testing.T) {
		f := conflictFixture(t)
		_, res := f.sync(t, PolicyKeepBoth)
		if res.Done != 4 {
			t.Errorf("performed %d operations, want 4: %+v", res.Done, res.Results)
		}
		for _, side := range []string{"A", "B"} {
			if got := f.read(t, side, keptA); got != aVersion {
				t.Errorf("side %s %s = %q, want side A's version", side, keptA, got)
			}
			if got := f.read(t, side, keptB); got != bVersion {
				t.Errorf("side %s %s = %q, want side B's version", side, keptB, got)
			}
		}
		// Each side keeps its own version at the original name.
		if f.read(t, "A", path) != aVersion || f.read(t, "B", path) != bVersion {
			t.Error("keep-both disturbed the original filename")
		}
	})

	t.Run("prefer-a wins at the path and preserves B's version", func(t *testing.T) {
		f := conflictFixture(t)
		_, res := f.sync(t, PolicyPreferA)
		if res.Done != 3 {
			t.Errorf("performed %d operations, want 3: %+v", res.Done, res.Results)
		}
		if got := f.read(t, "A", path); got != aVersion {
			t.Errorf("side A = %q", got)
		}
		if got := f.read(t, "B", path); got != aVersion {
			t.Errorf("side B = %q, want side A's version to have won", got)
		}
		for _, side := range []string{"A", "B"} {
			if got := f.read(t, side, keptB); got != bVersion {
				t.Errorf("side %s: the losing version was not preserved as %s (got %q)", side, keptB, got)
			}
		}
		if f.exists("A", keptA) || f.exists("B", keptA) {
			t.Error("prefer-a should not need to write an -a copy")
		}
	})

	t.Run("prefer-b wins at the path and preserves A's version", func(t *testing.T) {
		f := conflictFixture(t)
		_, res := f.sync(t, PolicyPreferB)
		if res.Done != 3 {
			t.Errorf("performed %d operations, want 3: %+v", res.Done, res.Results)
		}
		if got := f.read(t, "A", path); got != bVersion {
			t.Errorf("side A = %q, want side B's version to have won", got)
		}
		if got := f.read(t, "B", path); got != bVersion {
			t.Errorf("side B = %q", got)
		}
		for _, side := range []string{"A", "B"} {
			if got := f.read(t, side, keptA); got != aVersion {
				t.Errorf("side %s: the losing version was not preserved as %s (got %q)", side, keptA, got)
			}
		}
	})

	t.Run("no policy ever loses a version", func(t *testing.T) {
		for _, policy := range []string{PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB} {
			f := conflictFixture(t)
			f.sync(t, policy)
			foundA, foundB := false, false
			for _, side := range []string{"A", "B"} {
				for _, h := range f.snapshot(t, side) {
					if h == sum(aVersion) {
						foundA = true
					}
					if h == sum(bVersion) {
						foundB = true
					}
				}
			}
			if !foundA || !foundB {
				t.Errorf("policy %q lost a version (A kept=%v, B kept=%v)", policyLabel(policy), foundA, foundB)
			}
		}
	})

	t.Run("add-add conflict has no baseline hash", func(t *testing.T) {
		f := newFixture(t)
		f.write(t, "A", "x.txt", "from A")
		f.write(t, "B", "x.txt", "from B")
		p := f.plan(t, PolicyKeepBoth)
		if got := classOf(p, "x.txt"); got != ClassConflict {
			t.Fatalf("class = %q", got)
		}
		if p.Conflicts[0].BaseSHA256 != "" {
			t.Errorf("BaseSHA256 = %q, want empty", p.Conflicts[0].BaseSHA256)
		}
	})

	t.Run("suffixed name avoids an existing file", func(t *testing.T) {
		f := conflictFixture(t)
		f.writeBoth(t, keptA, "something else entirely")
		p := f.plan(t, PolicyKeepBoth)
		for _, name := range p.Conflicts[0].KeptAs {
			if name == keptA {
				t.Errorf("kept-as name %q collides with an existing file", name)
			}
		}
		if p.Conflicts[0].KeptAs[0] != "DCIM/IMG_0004.pocketsync-a-2.JPG" {
			t.Errorf("KeptAs = %v", p.Conflicts[0].KeptAs)
		}
	})
}

// ---------------------------------------------------------------------------
// 8. Baseline behaviour
// ---------------------------------------------------------------------------

func TestBaselineRecordsOnlyAgreedPaths(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "same.jpg", "same")
	f.write(t, "A", "onlyA.jpg", "a")
	f.write(t, "B", "differs.jpg", "b1")
	f.write(t, "A", "differs.jpg", "b2")

	m, st := mergeBaseline(nil, f.scan(t, "A"), f.scan(t, "B"))
	if len(m.Files) != 1 {
		t.Fatalf("baseline recorded %d paths, want 1: %v", len(m.Files), m.Files)
	}
	if _, ok := m.Files["same.jpg"]; !ok {
		t.Error("the agreed path was not recorded")
	}
	if st.Agreed != 1 || len(st.LeftOut) != 2 {
		t.Errorf("stats = %+v", st)
	}
}

func TestBaselineNeverOverwritesThePreviousOne(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "one.jpg", "one")
	f.recordBaseline(t)
	first, err := os.ReadFile(f.base)
	if err != nil {
		t.Fatal(err)
	}

	f.writeBoth(t, "two.jpg", "two")
	f.recordBaseline(t)

	matches, err := filepath.Glob(filepath.Join(f.dir, "baseline.prev-*.json"))
	if err != nil {
		t.Fatal(err)
	}
	if len(matches) != 1 {
		t.Fatalf("previous baselines kept: %v, want exactly 1", matches)
	}
	kept, err := os.ReadFile(matches[0])
	if err != nil {
		t.Fatal(err)
	}
	if string(kept) != string(first) {
		t.Error("the kept previous baseline is not the original content")
	}
	current, err := loadManifest(f.base)
	if err != nil {
		t.Fatal(err)
	}
	if len(current.Files) != 2 {
		t.Errorf("refreshed baseline has %d paths, want 2", len(current.Files))
	}
}

func TestBaselineCarriesForwardKnownDivergence(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "p.jpg", "p")
	f.recordBaseline(t)
	f.userDeletes(t, "A", "p.jpg")

	adv, err := advanceBaseline(f.base, f.A, f.B)
	if err != nil {
		t.Fatal(err)
	}
	if adv.Stats.Carried != 1 {
		t.Fatalf("stats = %+v, want the deleted path carried forward", adv.Stats)
	}
	m, err := loadManifest(f.base)
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := m.Files["p.jpg"]; !ok {
		t.Fatal("the path vanished from the baseline, which would resurrect the file on the next sync")
	}

	// Gone from both sides: the baseline finally drops it.
	f.userDeletes(t, "B", "p.jpg")
	adv2, err := advanceBaseline(f.base, f.A, f.B)
	if err != nil {
		t.Fatal(err)
	}
	if adv2.Stats.DroppedDeleted != 1 {
		t.Errorf("stats = %+v, want the path dropped once both sides agree it is gone", adv2.Stats)
	}
	m2, err := loadManifest(f.base)
	if err != nil {
		t.Fatal(err)
	}
	if len(m2.Files) != 0 {
		t.Errorf("baseline still holds %v", m2.Files)
	}
}

func TestMissingBaselineIsTreatedAsEmpty(t *testing.T) {
	f := newFixture(t)
	f.write(t, "A", "a.jpg", "a")
	f.write(t, "B", "b.jpg", "b")
	p := f.plan(t, PolicyNone)
	if p.Counts.NewA != 1 || p.Counts.NewB != 1 || p.Counts.DeletedA != 0 || p.Counts.DeletedB != 0 {
		t.Errorf("counts = %+v", p.Counts)
	}
	if len(p.Ops) != 2 {
		t.Errorf("ops = %+v", p.Ops)
	}
}

// ---------------------------------------------------------------------------
// 9. Copy safety
// ---------------------------------------------------------------------------

func TestCopyVerifiedLeavesNoPartFilesBehind(t *testing.T) {
	f := divergentFixture(t)
	f.sync(t, PolicyKeepBoth)
	for _, side := range []string{"A", "B"} {
		for p := range f.snapshot(t, side) {
			if strings.HasSuffix(p, partSuffix) {
				t.Errorf("side %s left a temporary file behind: %s", side, p)
			}
		}
	}
}

func TestRemoveTempRefusesAnythingButOurOwnTempFiles(t *testing.T) {
	d := t.TempDir()
	user := filepath.Join(d, "holiday.jpg")
	if err := os.WriteFile(user, []byte("precious"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := removeTemp(user); err == nil {
		t.Fatal("removeTemp accepted a user file")
	}
	if _, err := os.Stat(user); err != nil {
		t.Fatal("the user file was removed anyway")
	}

	temp := filepath.Join(d, "holiday.jpg"+partSuffix)
	if err := os.WriteFile(temp, []byte("half a copy"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := removeTemp(temp); err != nil {
		t.Fatalf("removeTemp refused its own temporary file: %v", err)
	}
	if _, err := os.Stat(temp); !os.IsNotExist(err) {
		t.Error("the temporary file survived")
	}
}

// The strongest guarantee this tool makes, checked against the source itself.
func TestSourceNeverUnlinksOutsideRemoveTemp(t *testing.T) {
	files, err := filepath.Glob("*.go")
	if err != nil {
		t.Fatal(err)
	}
	banned := []string{"os.RemoveAll(", "os.Truncate(", "syscall.Unlink(", "os.Link("}
	removeSites := 0
	for _, name := range files {
		if strings.HasSuffix(name, "_test.go") {
			continue
		}
		src, err := os.ReadFile(name)
		if err != nil {
			t.Fatal(err)
		}
		text := string(src)
		for _, b := range banned {
			if strings.Contains(text, b) {
				t.Errorf("%s calls %s", name, b)
			}
		}
		for i, line := range strings.Split(text, "\n") {
			trimmed := strings.TrimSpace(line)
			if strings.HasPrefix(trimmed, "//") {
				continue
			}
			if strings.Contains(trimmed, "os.Remove(") {
				removeSites++
				if name != "scan.go" || !strings.Contains(trimmed, "os.Remove(path)") {
					t.Errorf("%s:%d calls os.Remove outside removeTemp: %s", name, i+1, trimmed)
				}
			}
		}
	}
	if removeSites != 1 {
		t.Errorf("found %d os.Remove call sites, want exactly 1 (the one inside removeTemp)", removeSites)
	}
}

// ---------------------------------------------------------------------------
// 10. Scanning
// ---------------------------------------------------------------------------

func TestScanSkipsSymlinksAndPartFiles(t *testing.T) {
	d := t.TempDir()
	if err := os.WriteFile(filepath.Join(d, "real.jpg"), []byte("real"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(d, "half.jpg"+partSuffix), []byte("half"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(filepath.Join(d, "real.jpg"), filepath.Join(d, "link.jpg")); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	tr, err := scanTree(d, nil)
	if err != nil {
		t.Fatal(err)
	}
	if len(tr.Files) != 1 {
		t.Fatalf("scanned %v, want only real.jpg", tr.Files)
	}
	if len(tr.Parts) != 1 || len(tr.Skipped) != 1 {
		t.Errorf("parts=%v skipped=%v", tr.Parts, tr.Skipped)
	}
	if tr.Files["real.jpg"].SHA256 != sum("real") {
		t.Error("wrong hash recorded")
	}
}

func TestScanExcludesTheBaselineFileInsideATree(t *testing.T) {
	f := newFixture(t)
	f.writeBoth(t, "a.jpg", "a")
	inside := filepath.Join(f.A, "baseline.json")
	if err := os.WriteFile(inside, []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	tr, err := scanTree(f.A, map[string]bool{inside: true})
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := tr.Files["baseline.json"]; ok {
		t.Error("the baseline file was scanned as library content")
	}
}

// ---------------------------------------------------------------------------
// 11. Command line surface
// ---------------------------------------------------------------------------

func TestReorderFlags(t *testing.T) {
	got := reorderFlags([]string{"/a", "/b", "--conflict", "keep-both", "--json"}, valueFlags)
	want := []string{"--conflict", "keep-both", "--json", "/a", "/b"}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("reorderFlags = %v, want %v", got, want)
	}
}

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

func TestSummarySentence(t *testing.T) {
	f := divergentFixture(t)
	p := f.plan(t, PolicyNone)
	p.NameA, p.NameB = "the old phone", "the new one"
	s := summarySentence(p)
	for _, want := range []string{"new file on the old phone", "on the new one", "your decision",
		"renamed rather than added", "never propagated"} {
		if !strings.Contains(s, want) {
			t.Errorf("summary %q does not mention %q", s, want)
		}
	}
}

func buildBinary(t *testing.T) string {
	t.Helper()
	if _, err := exec.LookPath("go"); err != nil {
		t.Skip("go toolchain not available")
	}
	bin := filepath.Join(t.TempDir(), "pocketsync")
	cmd := exec.Command("go", "build", "-o", bin, ".")
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("build failed: %v\n%s", err, out)
	}
	return bin
}

func TestCommandLineExitCodes(t *testing.T) {
	bin := buildBinary(t)
	f := newFixture(t)
	f.writeBoth(t, "a.jpg", "a")

	cases := []struct {
		name     string
		args     []string
		wantCode int
		stdout   string
		stderr   string
	}{
		{"help", []string{"help"}, 0, "USAGE", ""},
		{"-h", []string{"-h"}, 0, "USAGE", ""},
		{"--help", []string{"--help"}, 0, "USAGE", ""},
		{"help after a subcommand", []string{"plan", "--help"}, 0, "USAGE", ""},
		{"no arguments", nil, 1, "", "USAGE"},
		{"unknown command", []string{"frobnicate"}, 1, "", "unknown command"},
		{"missing sides", []string{"plan"}, 1, "", "needs --a"},
		{"bad conflict policy", []string{"plan", "--a", f.A, "--b", f.B, "--conflict", "yolo"}, 1, "", "unknown --conflict policy"},
		{"missing directory", []string{"plan", "--a", filepath.Join(f.dir, "nope"), "--b", f.B}, 1, "", "does not exist"},
		{"same directory twice", []string{"plan", "--a", f.A, "--b", f.A}, 1, "", "same directory"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			cmd := exec.Command(bin, tc.args...)
			var out, errb strings.Builder
			cmd.Stdout, cmd.Stderr = &out, &errb
			err := cmd.Run()
			code := 0
			if err != nil {
				var ee *exec.ExitError
				if !errors.As(err, &ee) {
					t.Fatal(err)
				}
				code = ee.ExitCode()
			}
			if code != tc.wantCode {
				t.Errorf("exit code %d, want %d (stdout %q stderr %q)", code, tc.wantCode, out.String(), errb.String())
			}
			if tc.stdout != "" {
				if !strings.Contains(out.String(), tc.stdout) {
					t.Errorf("stdout does not contain %q: %q", tc.stdout, out.String())
				}
				if errb.Len() != 0 {
					t.Errorf("help wrote to stderr: %q", errb.String())
				}
			}
			if tc.stderr != "" {
				if !strings.Contains(errb.String(), tc.stderr) {
					t.Errorf("stderr does not contain %q: %q", tc.stderr, errb.String())
				}
				if out.Len() != 0 {
					t.Errorf("error path wrote to stdout: %q", out.String())
				}
			}
		})
	}
}

func TestCommandLineEndToEnd(t *testing.T) {
	bin := buildBinary(t)
	f := divergentFixture(t)

	run := func(args ...string) string {
		t.Helper()
		cmd := exec.Command(bin, args...)
		out, err := cmd.CombinedOutput()
		if err != nil {
			t.Fatalf("%v failed: %v\n%s", args, err, out)
		}
		return string(out)
	}

	beforeA, beforeB := f.snapshot(t, "A"), f.snapshot(t, "B")
	out := run("plan", "--a", f.A, "--b", f.B, "--baseline", f.base)
	if !strings.Contains(out, "NOTHING HAS BEEN CHANGED") {
		t.Errorf("plan output missing the dry-run statement:\n%s", out)
	}
	if !reflect.DeepEqual(beforeA, f.snapshot(t, "A")) || !reflect.DeepEqual(beforeB, f.snapshot(t, "B")) {
		t.Fatal("plan changed the trees")
	}

	out = run("sync", "--a", f.A, "--b", f.B, "--baseline", f.base)
	if !strings.Contains(out, "dry run") {
		t.Errorf("sync without --apply is not a dry run:\n%s", out)
	}
	if !reflect.DeepEqual(beforeA, f.snapshot(t, "A")) {
		t.Fatal("sync without --apply changed side A")
	}

	out = run("status", "--a", f.A, "--b", f.B, "--baseline", f.base, "--json")
	if !strings.Contains(out, `"deletion_guarantee"`) {
		t.Errorf("status --json lacks the deletion guarantee:\n%s", out)
	}

	run("sync", "--a", f.A, "--b", f.B, "--baseline", f.base, "--conflict", "keep-both", "--apply")
	if !f.exists("B", "DCIM/IMG_0100.JPG") {
		t.Error("the new photo was not copied A->B")
	}
	if !f.exists("A", "DCIM/IMG_0001.JPG") {
		t.Error("a deletion on B was propagated to A")
	}

	// Flags after positional arguments must work.
	out = run("status", f.A, f.B, "--baseline", f.base)
	if !strings.Contains(out, "PocketSync") {
		t.Errorf("positional form failed:\n%s", out)
	}
}
