package datastore

import (
	"strings"
	"testing"
)

func TestReplayFSLayerOverlayAncestorWhiteoutThenRenameKeepsHide(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir},
		{Path: "/repo/old/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/x.txt"},
	})
	if _, ok := tree["/repo/x.txt"]; ok {
		t.Fatal("rename under dir whiteout must not create dest")
	}
}

func TestReplayFSLayerOverlaySwapKeepsBothFiles(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/tmp.txt"},
		{Path: "/repo/b.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/a.txt"},
		{Path: "/repo/tmp.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
	})
	a, aOK := OverlayLookup(tree, "/repo/a.txt")
	b, bOK := OverlayLookup(tree, "/repo/b.txt")
	if !aOK || a.Whiteout || a.RenameFrom != "/repo/b.txt" {
		t.Fatalf("a=%+v ok=%v, want dest RenameFrom=b", a, aOK)
	}
	if !bOK || b.Whiteout || b.RenameFrom != "/repo/a.txt" {
		t.Fatalf("b=%+v ok=%v, want dest RenameFrom=a", b, bOK)
	}
	if n, ok := OverlayLookup(tree, "/repo/tmp.txt"); !ok || !n.Whiteout {
		t.Fatalf("tmp=%+v ok=%v, want whiteout", n, ok)
	}
	hiddenA, mainA := OverlayMainReadPathIf(tree, "/repo/a.txt", a, nil)
	hiddenB, mainB := OverlayMainReadPathIf(tree, "/repo/b.txt", b, nil)
	if hiddenA || mainA != "/repo/b.txt" || hiddenB || mainB != "/repo/a.txt" {
		t.Fatalf("mainA=%q hiddenA=%v mainB=%q hiddenB=%v", mainA, hiddenA, mainB, hiddenB)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawAB, sawBA bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpRename && d.Path == "/repo/a.txt" && d.ContentText == "/repo/b.txt" {
			sawAB = true
		}
		if d.Op == FSLayerEntryOpRename && d.Path == "/repo/b.txt" && d.ContentText == "/repo/a.txt" {
			sawBA = true
		}
	}
	if !sawAB || !sawBA {
		t.Fatalf("drafts=%+v, want 2-cycle a↔b", drafts)
	}
	broken := OverlayBreakRenameCycles(drafts)
	if len(broken) < 3 {
		t.Fatalf("broken=%+v, want temp hop plus both landings", broken)
	}
	var hopTmp, hopBA, hopTmpToDest bool
	for _, d := range broken {
		if d.Op != FSLayerEntryOpRename {
			continue
		}
		if strings.Contains(d.ContentText, ".fs-layer-swap-") {
			hopTmp = true
		}
		if d.Path == "/repo/b.txt" && d.ContentText == "/repo/a.txt" {
			hopBA = true
		}
		if strings.Contains(d.Path, ".fs-layer-swap-") && (d.ContentText == "/repo/a.txt" || d.ContentText == "/repo/b.txt") {
			hopTmpToDest = true
		}
	}
	if !hopTmp || !hopBA || !hopTmpToDest {
		t.Fatalf("broken=%+v, want a→tmp, b→a, tmp→dest", broken)
	}
}

func TestReplayFSLayerOverlaySelfRenameIsNoop(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("x")},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/a.txt"},
	})
	n, ok := OverlayLookup(tree, "/repo/a.txt")
	if !ok || n.Whiteout || !n.HasBody {
		t.Fatalf("node=%+v ok=%v, want upsert body kept", n, ok)
	}
}

func TestReplayFSLayerOverlayWhiteoutDirRenameKeepsDestDescendants(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/x/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir},
		{Path: "/repo/x/keep.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("keep"), SizeBytes: 4},
		{Path: "/repo/a/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir},
		{Path: "/repo/a/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/x/"},
	})
	n, ok := OverlayLookup(tree, "/repo/x/keep.txt")
	if !ok || !n.HasBody || string(n.Entry.ContentBlob) != "keep" {
		t.Fatalf("keep=%+v ok=%v, whiteout-src dir rename must not clear dest", n, ok)
	}
	if _, ok := OverlayLookup(tree, "/repo/x/"); !ok {
		t.Fatal("dest dir node missing after skipped rename")
	}
}

func TestReplayFSLayerOverlayAncestorHiddenDirRenameDoesNotMoveDescendants(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir},
		{Path: "/repo/old/sub/f.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("hid"), SizeBytes: 3},
		{Path: "/repo/old/sub/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/x/"},
	})
	if _, ok := OverlayLookup(tree, "/repo/x/f.txt"); ok {
		t.Fatal("ancestor-hidden dir rename must not move descendants to dest")
	}
	if _, ok := OverlayLookup(tree, "/repo/x/"); ok {
		t.Fatal("ancestor-hidden dir rename must not create dest")
	}
	if n, ok := OverlayLookup(tree, "/repo/old/sub/f.txt"); !ok || !n.HasBody {
		t.Fatalf("hidden upsert=%+v ok=%v, want left in place", n, ok)
	}
}

func TestReplayFSLayerOverlayDestInsideSrcIsNoop(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/a/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir},
		{Path: "/repo/a/keep.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("k"), SizeBytes: 1},
		{Path: "/repo/a/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/a/sub/"},
	})
	n, ok := OverlayLookup(tree, "/repo/a/keep.txt")
	if !ok || !n.HasBody {
		t.Fatalf("keep=%+v ok=%v, dest-inside-src must not whiteout source", n, ok)
	}
	if _, ok := OverlayLookup(tree, "/repo/a/sub/"); ok {
		t.Fatal("dest-inside-src must not create dest")
	}
}

func TestReplayFSLayerOverlayWhiteoutThenRenameKeepsHide(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindFile},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
	})
	if n, ok := tree["/repo/a.txt"]; !ok || !n.Whiteout {
		t.Fatalf("source=%+v ok=%v, want whiteout", n, ok)
	}
	if _, ok := tree["/repo/b.txt"]; ok {
		t.Fatal("rename after whiteout must not create a dest node")
	}
}

func TestReplayFSLayerOverlayWhiteoutThenChmodKeepsHide(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindFile},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpChmod, Kind: FSLayerEntryKindFile, Mode: 0o600},
	})
	n, ok := tree["/repo/a.txt"]
	if !ok || !n.Whiteout || n.HasBody {
		t.Fatalf("node=%+v, want remaining whiteout", n)
	}
}

func TestReplayFSLayerOverlayUpsertThenChmodKeepsBody(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("hello"), Mode: 0o644, ChecksumSHA256: "x", SizeBytes: 5},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpChmod, Kind: FSLayerEntryKindFile, Mode: 0o600},
	}
	tree := replayFSLayerOverlay(log)
	n, ok := tree["/repo/a.txt"]
	if !ok {
		t.Fatal("missing overlay node")
	}
	if n.Whiteout || !n.HasBody {
		t.Fatalf("node=%+v, want body kept after chmod", n)
	}
	if string(n.Entry.ContentBlob) != "hello" {
		t.Fatalf("content=%q, chmod must not drop upsert body", n.Entry.ContentBlob)
	}
	if n.Mode&0o777 != 0o600 {
		t.Fatalf("mode=%o, want 0600", n.Mode)
	}
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 1 || drafts[0].Op != FSLayerEntryOpUpsert {
		t.Fatalf("drafts=%+v, want one upsert", drafts)
	}
}

func TestReplayFSLayerOverlayRenameAndWhiteout(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("v"), SizeBytes: 1},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
	}
	tree := replayFSLayerOverlay(log)
	if n, ok := tree["/repo/a.txt"]; !ok || !n.Whiteout {
		t.Fatalf("src whiteout missing: %+v", tree["/repo/a.txt"])
	}
	if n, ok := tree["/repo/b.txt"]; !ok || !n.HasBody || string(n.Entry.ContentBlob) != "v" {
		t.Fatalf("dest missing body: %+v ok=%v", tree["/repo/b.txt"], ok)
	}
}

func TestReplayFSLayerOverlayMainBackedRenameKeepsRenameOp(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
	}
	tree := replayFSLayerOverlay(log)
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 1 {
		t.Fatalf("drafts=%+v, want single rename", drafts)
	}
	if drafts[0].Op != FSLayerEntryOpRename || drafts[0].Path != "/repo/a.txt" || drafts[0].ContentText != "/repo/b.txt" {
		t.Fatalf("draft=%+v, want rename a→b", drafts[0])
	}
	src := OverlayNodeEntry(tree["/repo/a.txt"])
	if src.Op != FSLayerEntryOpWhiteout {
		t.Fatalf("resolve src op=%s, want whiteout", src.Op)
	}
}

func TestReplayFSLayerOverlayDirectoryRenameMovesDescendants(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir, Mode: 0o755},
		{Path: "/repo/old/child.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("v"), SizeBytes: 1},
		{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/"},
	}
	tree := replayFSLayerOverlay(log)
	if n, ok := tree["/repo/old/"]; !ok || !n.Whiteout {
		t.Fatalf("src dir whiteout missing: %+v", tree["/repo/old/"])
	}
	if _, ok := tree["/repo/old/child.txt"]; ok {
		t.Fatal("stale descendant left under old prefix")
	}
	n, ok := tree["/repo/new/child.txt"]
	if !ok || !n.HasBody || string(n.Entry.ContentBlob) != "v" {
		t.Fatalf("moved child = %+v ok=%v", n, ok)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawChild, sawOldChild bool
	for _, d := range drafts {
		if d.Path == "/repo/new/child.txt" && d.Op == FSLayerEntryOpUpsert {
			sawChild = true
		}
		if d.Path == "/repo/old/child.txt" {
			sawOldChild = true
		}
	}
	if !sawChild || sawOldChild {
		t.Fatalf("drafts=%+v, want upsert at new child and no old child", drafts)
	}
}

func TestReplayFSLayerOverlayMainBackedDirRenameMovesOverlayChild(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/old/extra.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("x"), SizeBytes: 1},
		{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/"},
	}
	tree := replayFSLayerOverlay(log)
	if _, ok := tree["/repo/old/extra.txt"]; ok {
		t.Fatal("overlay child should move with directory rename")
	}
	n, ok := tree["/repo/new/extra.txt"]
	if !ok || string(n.Entry.ContentBlob) != "x" {
		t.Fatalf("moved overlay child = %+v ok=%v", n, ok)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawRename bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpRename && d.Path == "/repo/old/" && d.ContentText == "/repo/new/" {
			sawRename = true
		}
	}
	if !sawRename {
		t.Fatalf("drafts=%+v, want main-backed dir rename", drafts)
	}
}

func TestReplayFSLayerOverlayPreservesTrailingSpaceRenameTarget(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/trailing "},
	}
	tree := replayFSLayerOverlay(log)
	n, ok := tree["/repo/trailing "]
	if !ok || n.RenameFrom != "/repo/a.txt" {
		t.Fatalf("dest node=%+v ok=%v, want rename to trailing-space path", n, ok)
	}
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 1 || drafts[0].ContentText != "/repo/trailing " {
		t.Fatalf("drafts=%+v, want preserved trailing space", drafts)
	}
}

func TestReplayFSLayerOverlayChmodAfterMainBackedRename(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt", Mode: 0o644},
		{Path: "/repo/b.txt", Op: FSLayerEntryOpChmod, Kind: FSLayerEntryKindFile, Mode: 0o600},
	}
	tree := replayFSLayerOverlay(log)
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 2 {
		t.Fatalf("drafts=%+v, want rename then chmod", drafts)
	}
	if drafts[0].Op != FSLayerEntryOpRename || drafts[0].Path != "/repo/a.txt" || drafts[0].ContentText != "/repo/b.txt" {
		t.Fatalf("first draft=%+v, want rename a→b", drafts[0])
	}
	if drafts[1].Op != FSLayerEntryOpChmod || drafts[1].Path != "/repo/b.txt" || drafts[1].Mode&0o777 != 0o600 {
		t.Fatalf("second draft=%+v, want chmod b 0600", drafts[1])
	}
}

func TestReplayFSLayerOverlayWhiteoutAfterRenameKeepsSourceDelete(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{Path: "/repo/b.txt", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindFile},
	}
	tree := replayFSLayerOverlay(log)
	src := tree["/repo/a.txt"]
	if !src.Whiteout || src.ConsumedByRename {
		t.Fatalf("src=%+v, want real whiteout after dest delete", src)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawSrc, sawDest bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpWhiteout && d.Path == "/repo/a.txt" {
			sawSrc = true
		}
		if d.Op == FSLayerEntryOpWhiteout && d.Path == "/repo/b.txt" {
			sawDest = true
		}
	}
	if !sawSrc || !sawDest {
		t.Fatalf("drafts=%+v, want whiteouts for both a and b", drafts)
	}
}

func TestReplayFSLayerOverlayRenameOntoRenameDestUnconsumesPreviousSource(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{Path: "/repo/c.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
	}
	tree := replayFSLayerOverlay(log)
	src := tree["/repo/a.txt"]
	if !src.Whiteout || src.ConsumedByRename {
		t.Fatalf("a=%+v, want unconsumed whiteout after later rename onto b", src)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawA, sawRenameC bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpWhiteout && d.Path == "/repo/a.txt" {
			sawA = true
		}
		if d.Op == FSLayerEntryOpRename && d.Path == "/repo/c.txt" && d.ContentText == "/repo/b.txt" {
			sawRenameC = true
		}
	}
	if !sawA || !sawRenameC {
		t.Fatalf("drafts=%+v, want delete a and rename c→b", drafts)
	}
}

func TestReplayFSLayerOverlayRecreateSourceAppliesAfterRename(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("new"), SizeBytes: 3},
	}
	tree := replayFSLayerOverlay(log)
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 2 {
		t.Fatalf("drafts=%+v, want rename then upsert", drafts)
	}
	if drafts[0].Op != FSLayerEntryOpRename || drafts[0].Path != "/repo/a.txt" || drafts[0].ContentText != "/repo/b.txt" {
		t.Fatalf("first draft=%+v, want rename a→b before recreate", drafts[0])
	}
	if drafts[1].Op != FSLayerEntryOpUpsert || drafts[1].Path != "/repo/a.txt" || string(drafts[1].ContentBlob) != "new" {
		t.Fatalf("second draft=%+v, want upsert new a", drafts[1])
	}
}

func TestReplayFSLayerOverlayRecreateSourceAfterRenameIntoNewDir(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/new/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir, Mode: 0o755},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/new/a.txt"},
		{Path: "/repo/a.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("new"), SizeBytes: 3},
		{Path: "/repo/n.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("n"), SizeBytes: 1},
	}
	drafts := OverlayCommitDrafts(replayFSLayerOverlay(log))
	idx := map[string]int{}
	for i, d := range drafts {
		switch {
		case d.Op == FSLayerEntryOpMkdir && d.Path == "/repo/new/":
			idx["mkdir"] = i
		case d.Op == FSLayerEntryOpRename && d.ContentText == "/repo/new/a.txt":
			idx["rename"] = i
		case d.Op == FSLayerEntryOpUpsert && d.Path == "/repo/a.txt":
			idx["recreate"] = i
		}
	}
	if len(idx) != 3 {
		t.Fatalf("drafts=%+v, missing mkdir/rename/recreate", drafts)
	}
	if idx["mkdir"] >= idx["rename"] || idx["rename"] >= idx["recreate"] {
		t.Fatalf("order mkdir=%d rename=%d recreate=%d, want mkdir < rename < recreate; drafts=%+v", idx["mkdir"], idx["rename"], idx["recreate"], drafts)
	}
}

func TestReplayFSLayerOverlayMkdirRecreateAfterDirRename(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/"},
		{Path: "/repo/old/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir, Mode: 0o755},
	}
	drafts := OverlayCommitDrafts(replayFSLayerOverlay(log))
	if len(drafts) != 2 {
		t.Fatalf("drafts=%+v, want rename then mkdir", drafts)
	}
	if drafts[0].Op != FSLayerEntryOpRename || drafts[0].Path != "/repo/old/" || drafts[0].ContentText != "/repo/new/" {
		t.Fatalf("first=%+v, want dir rename", drafts[0])
	}
	if drafts[1].Op != FSLayerEntryOpMkdir || drafts[1].Path != "/repo/old/" {
		t.Fatalf("second=%+v, want mkdir recreate after rename", drafts[1])
	}
}

func TestReplayFSLayerOverlayRenameChainKeepsIntermediateWhiteout(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{Path: "/repo/b.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/c.txt"},
	}
	tree := replayFSLayerOverlay(log)
	mid := tree["/repo/b.txt"]
	if !mid.Whiteout || mid.ConsumedByRename {
		t.Fatalf("b=%+v, want real intermediate whiteout", mid)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawRename, sawMid bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpRename && d.Path == "/repo/a.txt" && d.ContentText == "/repo/c.txt" {
			sawRename = true
		}
		if d.Op == FSLayerEntryOpWhiteout && d.Path == "/repo/b.txt" {
			sawMid = true
		}
	}
	if !sawRename || !sawMid {
		t.Fatalf("drafts=%+v, want rename a→c and whiteout b", drafts)
	}
}

func TestReplayFSLayerOverlayUpsertAfterRenameKeepsSourceDelete(t *testing.T) {
	log := []FSLayerEntry{
		{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{Path: "/repo/b.txt", Op: FSLayerEntryOpUpsert, Kind: FSLayerEntryKindFile, ContentBlob: []byte("new"), SizeBytes: 3},
	}
	tree := replayFSLayerOverlay(log)
	src := tree["/repo/a.txt"]
	if !src.Whiteout || src.ConsumedByRename {
		t.Fatalf("src=%+v, want real whiteout after dest upsert", src)
	}
	drafts := OverlayCommitDrafts(tree)
	var sawUpsert, sawWhiteout bool
	for _, d := range drafts {
		if d.Op == FSLayerEntryOpUpsert && d.Path == "/repo/b.txt" {
			sawUpsert = true
		}
		if d.Op == FSLayerEntryOpWhiteout && d.Path == "/repo/a.txt" {
			sawWhiteout = true
		}
		if d.Op == FSLayerEntryOpRename {
			t.Fatalf("unexpected rename after dest upsert: %+v", d)
		}
	}
	if !sawUpsert || !sawWhiteout {
		t.Fatalf("drafts=%+v, want dest upsert and src whiteout", drafts)
	}
}

func TestOverlayCommitDraftsSortsRenameByDestination(t *testing.T) {
	tree := map[string]FSLayerOverlayNode{
		"/repo/new/": {
			Path:    "/repo/new/",
			HasBody: true,
			Kind:    FSLayerEntryKindDir,
			Entry:   FSLayerEntry{Path: "/repo/new/", Op: FSLayerEntryOpMkdir, Kind: FSLayerEntryKindDir},
		},
		"/repo/new/a.txt": {
			Path:       "/repo/new/a.txt",
			RenameFrom: "/repo/a.txt",
			Kind:       FSLayerEntryKindFile,
			Entry:      FSLayerEntry{Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/new/a.txt"},
		},
		"/repo/a.txt": {
			Path:             "/repo/a.txt",
			Whiteout:         true,
			ConsumedByRename: true,
			Kind:             FSLayerEntryKindFile,
			Entry:            FSLayerEntry{Path: "/repo/a.txt", Op: FSLayerEntryOpWhiteout},
		},
	}
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 2 {
		t.Fatalf("drafts=%+v, want mkdir then rename", drafts)
	}
	if drafts[0].Op != FSLayerEntryOpMkdir || drafts[0].Path != "/repo/new/" {
		t.Fatalf("first draft=%+v, want mkdir dest parent", drafts[0])
	}
	if drafts[1].Op != FSLayerEntryOpRename || drafts[1].ContentText != "/repo/new/a.txt" {
		t.Fatalf("second draft=%+v, want rename into dest", drafts[1])
	}
}

func TestOverlayCommitDraftsWhiteoutDeepestFirst(t *testing.T) {
	tree := map[string]FSLayerOverlayNode{
		"/repo/a/":      {Path: "/repo/a/", Whiteout: true, Kind: FSLayerEntryKindDir, Entry: FSLayerEntry{Path: "/repo/a/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir}},
		"/repo/a/b.txt": {Path: "/repo/a/b.txt", Whiteout: true, Kind: FSLayerEntryKindFile, Entry: FSLayerEntry{Path: "/repo/a/b.txt", Op: FSLayerEntryOpWhiteout}},
	}
	drafts := OverlayCommitDrafts(tree)
	if len(drafts) != 2 {
		t.Fatalf("drafts=%d", len(drafts))
	}
	if drafts[0].Path != "/repo/a/b.txt" || drafts[1].Path != "/repo/a/" {
		t.Fatalf("order=%q then %q, want file then dir", drafts[0].Path, drafts[1].Path)
	}
}

func TestOverlayCommitDraftsKeepsCopyUpSnapshot(t *testing.T) {
	tree := map[string]FSLayerOverlayNode{
		"/repo/a.txt": {
			Path:    "/repo/a.txt",
			HasBody: true,
			Kind:    FSLayerEntryKindFile,
			Entry: FSLayerEntry{
				Path:         "/repo/a.txt",
				Op:           FSLayerEntryOpUpsert,
				Kind:         FSLayerEntryKindFile,
				BaseInodeID:  "inode-1",
				BaseRevision: 3,
				ContentBlob:  []byte("layer"),
			},
		},
		"/repo/b.txt": {
			Path:       "/repo/b.txt",
			RenameFrom: "/repo/a-src.txt",
			Kind:       FSLayerEntryKindFile,
			Mode:       0o600,
			ModeSet:    true,
			Entry: FSLayerEntry{
				Path:         "/repo/a-src.txt",
				Op:           FSLayerEntryOpRename,
				Kind:         FSLayerEntryKindFile,
				ContentText:  "/repo/b.txt",
				BaseInodeID:  "inode-src",
				BaseRevision: 4,
			},
		},
	}
	drafts := OverlayCommitDrafts(tree)
	var upsert, rename, chmod *FSLayerEntry
	for i := range drafts {
		e := &drafts[i]
		switch {
		case e.Op == FSLayerEntryOpUpsert && e.Path == "/repo/a.txt":
			upsert = e
		case e.Op == FSLayerEntryOpRename && e.Path == "/repo/a-src.txt":
			rename = e
		case e.Op == FSLayerEntryOpChmod && e.Path == "/repo/b.txt":
			chmod = e
		}
	}
	if upsert == nil || upsert.BaseRevision != 3 || upsert.BaseInodeID != "inode-1" {
		t.Fatalf("upsert=%+v, want copy-up snapshot", upsert)
	}
	if rename == nil || rename.BaseRevision != 4 || rename.BaseInodeID != "inode-src" {
		t.Fatalf("rename=%+v, want source snapshot", rename)
	}
	if chmod == nil || chmod.BaseRevision != 4 || chmod.BaseInodeID != "inode-src" {
		t.Fatalf("dest chmod=%+v, want source snapshot", chmod)
	}
}

func TestReplayFSLayerOverlayRenameClearsMovedBodySnapshot(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/move.txt",
			Op:           FSLayerEntryOpUpsert,
			Kind:         FSLayerEntryKindFile,
			BaseInodeID:  "inode-src",
			BaseRevision: 2,
			ContentBlob:  []byte("layered"),
		},
		{
			Path:         "/repo/move.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/moved.txt",
			BaseInodeID:  "inode-src",
			BaseRevision: 2,
		},
	})
	dest, ok := OverlayLookup(tree, "/repo/moved.txt")
	if !ok || !dest.HasBody {
		t.Fatalf("dest=%+v ok=%v, want moved body", dest, ok)
	}
	if dest.Entry.BaseRevision != 0 || dest.Entry.BaseInodeID != "" {
		t.Fatalf("dest snapshot=%+v, want cleared", dest.Entry)
	}
	src, srcOK := OverlayLookup(tree, "/repo/move.txt")
	if !srcOK || !src.Whiteout {
		t.Fatalf("src=%+v ok=%v, want whiteout", src, srcOK)
	}
	if src.Entry.BaseRevision != 2 || src.Entry.BaseInodeID != "inode-src" {
		t.Fatalf("src whiteout snapshot=%+v, want copy-up", src.Entry)
	}
	drafts := OverlayCommitDrafts(tree)
	var upsert, whiteout *FSLayerEntry
	for i := range drafts {
		e := &drafts[i]
		switch {
		case e.Op == FSLayerEntryOpUpsert && e.Path == "/repo/moved.txt":
			upsert = e
		case e.Op == FSLayerEntryOpWhiteout && e.Path == "/repo/move.txt":
			whiteout = e
		}
	}
	if upsert == nil || upsert.BaseRevision != 0 {
		t.Fatalf("dest upsert=%+v, want empty snapshot", upsert)
	}
	if whiteout == nil || whiteout.BaseRevision != 2 {
		t.Fatalf("src whiteout=%+v, want copy-up snapshot", whiteout)
	}
}

func TestReplayFSLayerOverlayBodyRenameKeepsOpStampWhenSrcClaimless(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/a.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/b.txt",
			BaseInodeID:  "inode-a",
			BaseRevision: 5,
		},
		{
			Path:        "/repo/b.txt",
			Op:          FSLayerEntryOpUpsert,
			Kind:        FSLayerEntryKindFile,
			ContentBlob: []byte("new"),
			SizeBytes:   3,
		},
		{
			Path:         "/repo/b.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/c.txt",
			BaseInodeID:  "inode-b",
			BaseRevision: 9,
		},
	})
	wo, ok := OverlayLookup(tree, "/repo/b.txt")
	if !ok || !wo.Whiteout {
		t.Fatalf("b=%+v ok=%v, want whiteout", wo, ok)
	}
	if wo.Entry.BaseRevision != 9 || wo.Entry.BaseInodeID != "inode-b" {
		t.Fatalf("b whiteout snapshot=%+v, want rename op stamp b@9", wo.Entry)
	}
}

func TestReplayFSLayerOverlayFoldedRenameKeepsSourceSnapshot(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/a.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/b.txt",
			BaseInodeID:  "inode-a",
			BaseRevision: 5,
		},
		{
			Path:        "/repo/b.txt",
			Op:          FSLayerEntryOpRename,
			Kind:        FSLayerEntryKindFile,
			ContentText: "/repo/c.txt",
		},
	})
	dest, ok := OverlayLookup(tree, "/repo/c.txt")
	if !ok || dest.RenameFrom != "/repo/a.txt" {
		t.Fatalf("dest=%+v ok=%v, want RenameFrom=a", dest, ok)
	}
	if dest.Entry.BaseRevision != 5 || dest.Entry.BaseInodeID != "inode-a" {
		t.Fatalf("folded rename snapshot=%+v, want source copy-up", dest.Entry)
	}
	drafts := OverlayCommitDrafts(tree)
	var rename *FSLayerEntry
	for i := range drafts {
		if drafts[i].Op == FSLayerEntryOpRename && drafts[i].Path == "/repo/a.txt" && drafts[i].ContentText == "/repo/c.txt" {
			rename = &drafts[i]
			break
		}
	}
	if rename == nil || rename.BaseRevision != 5 || rename.BaseInodeID != "inode-a" {
		t.Fatalf("draft=%+v, want a->c with source snapshot", rename)
	}

	stampedLater := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/a.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/b.txt",
			BaseInodeID:  "inode-a",
			BaseRevision: 5,
		},
		{
			Path:         "/repo/b.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/c.txt",
			BaseInodeID:  "inode-b",
			BaseRevision: 9,
		},
	})
	destLater, okLater := OverlayLookup(stampedLater, "/repo/c.txt")
	if !okLater || destLater.Entry.BaseRevision != 5 || destLater.Entry.BaseInodeID != "inode-a" {
		t.Fatalf("folded dest snapshot=%+v, want a@5 not later hop b@9", destLater.Entry)
	}
	wo, woOK := OverlayLookup(stampedLater, "/repo/b.txt")
	if !woOK || !wo.Whiteout || wo.Entry.BaseRevision != 9 {
		t.Fatalf("intermediate whiteout=%+v, want b@9", wo.Entry)
	}
}

func TestReplayFSLayerOverlayDirMoveKeepsExternalRenameSnapshot(t *testing.T) {
	keep := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/x.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/dir/x.txt",
			BaseInodeID:  "inode-x",
			BaseRevision: 1,
		},
		{
			Path:        "/repo/dir/",
			Op:          FSLayerEntryOpRename,
			Kind:        FSLayerEntryKindDir,
			ContentText: "/repo/newdir/",
		},
	})
	kept, ok := OverlayLookup(keep, "/repo/newdir/x.txt")
	if !ok || kept.RenameFrom != "/repo/x.txt" {
		t.Fatalf("kept=%+v ok=%v, want RenameFrom=/repo/x.txt", kept, ok)
	}
	if kept.Entry.BaseRevision != 1 || kept.Entry.BaseInodeID != "inode-x" {
		t.Fatalf("kept snapshot=%+v, want x@1", kept.Entry)
	}
	drafts := OverlayCommitDrafts(keep)
	var rename *FSLayerEntry
	for i := range drafts {
		if drafts[i].Op == FSLayerEntryOpRename && drafts[i].Path == "/repo/x.txt" && drafts[i].ContentText == "/repo/newdir/x.txt" {
			rename = &drafts[i]
			break
		}
	}
	if rename == nil || rename.BaseRevision != 1 {
		t.Fatalf("draft=%+v, want x -> newdir/x with snapshot", rename)
	}

	cleared := replayFSLayerOverlay([]FSLayerEntry{
		{
			Path:         "/repo/dir/a.txt",
			Op:           FSLayerEntryOpRename,
			Kind:         FSLayerEntryKindFile,
			ContentText:  "/repo/dir/b.txt",
			BaseInodeID:  "inode-a",
			BaseRevision: 2,
		},
		{
			Path:        "/repo/dir/",
			Op:          FSLayerEntryOpRename,
			Kind:        FSLayerEntryKindDir,
			ContentText: "/repo/newdir/",
		},
	})
	moved, movedOK := OverlayLookup(cleared, "/repo/newdir/b.txt")
	if !movedOK || moved.RenameFrom != "/repo/newdir/a.txt" {
		t.Fatalf("moved=%+v ok=%v, want rewritten RenameFrom", moved, movedOK)
	}
	if moved.Entry.BaseRevision != 0 || moved.Entry.BaseInodeID != "" {
		t.Fatalf("inside-tree snapshot=%+v, want cleared", moved.Entry)
	}
}

func TestOverlayHideOrRemapAncestorDirWhiteout(t *testing.T) {
	tree := map[string]FSLayerOverlayNode{
		"/repo/dir/": {Path: "/repo/dir/", Whiteout: true, Kind: FSLayerEntryKindDir},
	}
	hidden, mapped := OverlayHideOrRemap(tree, "/repo/dir/a.txt")
	if !hidden || mapped != "" {
		t.Fatalf("hidden=%v mapped=%q, want hidden", hidden, mapped)
	}
}

func TestOverlayRemapToMainNestedDirHops(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/tmp/"},
		{Path: "/repo/tmp/sub/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/sub/"},
	})
	hidden, mapped := OverlayHideOrRemap(tree, "/repo/new/sub/a.txt")
	if hidden || mapped != "/repo/old/sub/a.txt" {
		t.Fatalf("hidden=%v mapped=%q, want /repo/old/sub/a.txt", hidden, mapped)
	}
	if hiddenOld := OverlayViewHidden(tree, "/repo/old/sub/a.txt"); !hiddenOld {
		t.Fatal("old path should stay hidden")
	}
}

func overlayLandedIDs(ids ...string) func(FSLayerRenameHop) bool {
	set := make(map[string]struct{}, len(ids))
	for _, id := range ids {
		set[id] = struct{}{}
	}
	return func(hop FSLayerRenameHop) bool {
		_, ok := set[hop.LayerID]
		return ok
	}
}

func TestOverlayRemapSkipsCommittedAncestorHops(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{LayerID: "parent", Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/tmp/"},
		{LayerID: "child", Path: "/repo/tmp/sub/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/sub/"},
	})
	hidden, mapped := OverlayHideOrRemapIf(tree, "/repo/new/sub/a.txt", overlayLandedIDs("parent"))
	if hidden || mapped != "/repo/tmp/sub/a.txt" {
		t.Fatalf("hidden=%v mapped=%q, want live parent dest /repo/tmp/sub/a.txt", hidden, mapped)
	}
}

func TestOverlayLiveRenameSourceStopsAtCommittedHop(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{LayerID: "parent", Path: "/repo/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/b.txt"},
		{LayerID: "child", Path: "/repo/b.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/c.txt"},
	})
	n, ok := OverlayLookup(tree, "/repo/c.txt")
	if !ok || n.RenameFrom != "/repo/a.txt" || n.RenameVia != "/repo/b.txt" {
		t.Fatalf("dest=%+v ok=%v, want fold a and via b", n, ok)
	}
	if got := OverlayLiveRenameSourceIf(n, nil); got != "/repo/a.txt" {
		t.Fatalf("uncommitted live=%q, want a", got)
	}
	if got := OverlayLiveRenameSourceIf(n, overlayLandedIDs("parent")); got != "/repo/b.txt" {
		t.Fatalf("parent-committed live=%q, want b", got)
	}
	hidden, mainPath := OverlayMainReadPathIf(tree, "/repo/c.txt", n, overlayLandedIDs("parent"))
	if hidden || mainPath != "/repo/b.txt" {
		t.Fatalf("hidden=%v mainPath=%q, want b", hidden, mainPath)
	}
}

func TestOverlayRemapDirContinuationUsesLiveSource(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{LayerID: "parent", Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/tmp/"},
		{LayerID: "child", Path: "/repo/tmp/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/x/"},
	})
	hidden, mapped := OverlayHideOrRemap(tree, "/repo/x/sub/a.txt")
	if hidden || mapped != "/repo/old/sub/a.txt" {
		t.Fatalf("uncommitted hidden=%v mapped=%q, want /repo/old/sub/a.txt", hidden, mapped)
	}
	hidden, mapped = OverlayHideOrRemapIf(tree, "/repo/x/sub/a.txt", overlayLandedIDs("parent"))
	if hidden || mapped != "/repo/tmp/sub/a.txt" {
		t.Fatalf("parent-committed hidden=%v mapped=%q, want /repo/tmp/sub/a.txt", hidden, mapped)
	}
	if n, ok := OverlayLookup(tree, "/repo/tmp/"); !ok || !n.Whiteout {
		t.Fatalf("tmp=%+v ok=%v, want intermediate whiteout", n, ok)
	}
}

func TestOverlayRemapToMainDoesNotGlueSiblingDest(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/"},
		{Path: "/repo/other/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/sub/"},
	})
	hidden, mapped := OverlayHideOrRemap(tree, "/repo/new/sub/a.txt")
	if hidden || mapped != "/repo/other/a.txt" {
		t.Fatalf("hidden=%v mapped=%q, want /repo/other/a.txt", hidden, mapped)
	}
}

func TestOverlayMainReadPathFileOutThenDirWhiteout(t *testing.T) {
	tree := replayFSLayerOverlay([]FSLayerEntry{
		{Path: "/repo/old/a.txt", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindFile, ContentText: "/repo/x.txt"},
		{Path: "/repo/old/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir},
	})
	n, ok := OverlayLookup(tree, "/repo/x.txt")
	if !ok || n.Whiteout || n.RenameFrom != "/repo/old/a.txt" {
		t.Fatalf("dest node=%+v ok=%v", n, ok)
	}
	hidden, mainPath := OverlayMainReadPathIf(tree, "/repo/x.txt", n, nil)
	if hidden || mainPath != "/repo/old/a.txt" {
		t.Fatalf("hidden=%v mainPath=%q, want main /repo/old/a.txt", hidden, mainPath)
	}
	if hiddenView := OverlayViewHidden(tree, "/repo/old/a.txt"); !hiddenView {
		t.Fatal("old view path should stay hidden")
	}
}

func TestOverlayHideOrRemapAncestorDirRename(t *testing.T) {
	tree := map[string]FSLayerOverlayNode{
		"/repo/old/": {
			Path:     "/repo/old/",
			Whiteout: true, ConsumedByRename: true,
			Kind:  FSLayerEntryKindDir,
			Entry: FSLayerEntry{Path: "/repo/old/", Op: FSLayerEntryOpWhiteout, Kind: FSLayerEntryKindDir},
		},
		"/repo/new/": {
			Path:       "/repo/new/",
			RenameFrom: "/repo/old/",
			Kind:       FSLayerEntryKindDir,
			Entry:      FSLayerEntry{Path: "/repo/old/", Op: FSLayerEntryOpRename, Kind: FSLayerEntryKindDir, ContentText: "/repo/new/"},
		},
	}
	hidden, _ := OverlayHideOrRemap(tree, "/repo/old/a.txt")
	if !hidden {
		t.Fatal("old descendant should be hidden by rename-source whiteout")
	}
	hidden, mapped := OverlayHideOrRemap(tree, "/repo/new/a.txt")
	if hidden || mapped != "/repo/old/a.txt" {
		t.Fatalf("hidden=%v mapped=%q, want remap to /repo/old/a.txt", hidden, mapped)
	}
}
