package diff

import (
	"errors"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"github.com/blacktop/go-macho"
	"github.com/blacktop/ipsw/internal/diff/storage"
	"github.com/blacktop/ipsw/internal/search"
	"github.com/blacktop/ipsw/pkg/info"
	"github.com/blacktop/ipsw/pkg/plist"
)

type fakeVolumeFileSession struct {
	roots    map[string]string
	mounted  map[string]bool
	released []string
	// rootCalls counts every Root invocation so a full-cache-hit test can assert
	// the orchestrator mounted ZERO volumes (excludeHydrated empties every
	// volume's active set, so the loop continues without ever calling Root).
	rootCalls int
}

func (f *fakeVolumeFileSession) Root(typ string) (string, error) {
	f.rootCalls++
	root, ok := f.roots[typ]
	if !ok {
		return "", errors.New("unexpected root request: " + typ)
	}
	if f.mounted == nil {
		f.mounted = make(map[string]bool)
	}
	f.mounted[typ] = true
	return root, nil
}

func (f *fakeVolumeFileSession) Release(typ string) error {
	if !f.mounted[typ] {
		return nil
	}
	delete(f.mounted, typ)
	f.released = append(f.released, typ)
	return nil
}

func TestIPSWSessionExtractDirsArePerSide(t *testing.T) {
	tmpDir := t.TempDir()

	oldDir := ipswSessionExtractDir(tmpDir, "old")
	newDir := ipswSessionExtractDir(tmpDir, "new")

	if oldDir == newDir {
		t.Fatalf("old and new extract dirs are equal: %s", oldDir)
	}
	for _, dir := range []string{oldDir, newDir} {
		rel, err := filepath.Rel(tmpDir, dir)
		if err != nil {
			t.Fatalf("Rel(%s, %s): %v", tmpDir, dir, err)
		}
		if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
			t.Fatalf("extract dir %s is not under temp dir %s", dir, tmpDir)
		}
	}

	dmgName := "shared-volume-name.dmg"
	oldDMG := filepath.Join(oldDir, dmgName)
	newDMG := filepath.Join(newDir, dmgName)
	if oldDMG == newDMG {
		t.Fatalf("same-named DMGs collide: %s", oldDMG)
	}
}

func TestIndexIdenticalIPSWArtifactsUsesBuildManifestDigests(t *testing.T) {
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"KernelCache":       {path: "kernelcache.release.v53", digest: []byte{0x01}},
		"OS":                {path: "094-55036-099.dmg.aea", digest: []byte{0x02}},
		"Cryptex1,SystemOS": {path: "094-55682-100.dmg.aea", digest: []byte{0x03}},
		"Cryptex1,AppOS":    {path: "094-54871-103.dmg", digest: []byte{0x04}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"KernelCache":       {path: "kernelcache.release.v53", digest: []byte{0x01}},
		"OS":                {path: "094-55036-101.dmg.aea", digest: []byte{0x02}},
		"Cryptex1,SystemOS": {path: "094-55682-102.dmg.aea", digest: []byte{0x03}},
		"Cryptex1,AppOS":    {path: "094-54871-105.dmg", digest: []byte{0x04}},
	})
	d := &Diff{
		Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
		New: Context{InputMode: inputModeIPSW, Info: newInfo},
	}

	d.indexIdenticalIPSWArtifacts()

	if !d.sameKernel {
		t.Fatal("sameKernel = false, want true")
	}
	for _, typ := range []string{"fs", "sys", "app"} {
		if !d.ipswVolumeUnchanged(typ) {
			t.Fatalf("%s unchanged = false, want true", typ)
		}
	}
	if !d.allIPSWOSVolumesUnchanged() {
		t.Fatal("allIPSWOSVolumesUnchanged() = false, want true")
	}
}

func TestIndexIdenticalIPSWArtifactsFailsClosedOnDigestMismatch(t *testing.T) {
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"KernelCache":       {path: "kernelcache.release.v53", digest: []byte{0x01}},
		"OS":                {path: "094-55036-099.dmg.aea", digest: []byte{0x02}},
		"Cryptex1,SystemOS": {path: "094-55682-100.dmg.aea", digest: []byte{0x03}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"KernelCache":       {path: "kernelcache.release.v53", digest: []byte{0x09}},
		"OS":                {path: "094-55036-101.dmg.aea", digest: []byte{0x02}},
		"Cryptex1,SystemOS": {path: "094-55682-102.dmg.aea", digest: []byte{0x08}},
	})
	d := &Diff{
		Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
		New: Context{InputMode: inputModeIPSW, Info: newInfo},
	}

	d.indexIdenticalIPSWArtifacts()

	if d.sameKernel {
		t.Fatal("sameKernel = true, want false")
	}
	if d.ipswVolumeUnchanged("sys") {
		t.Fatal("sys unchanged = true, want false")
	}
	if d.allIPSWOSVolumesUnchanged() {
		t.Fatal("allIPSWOSVolumesUnchanged() = true, want false")
	}
}

func TestDSCVolumeUnchangedFallsBackToFilesystemDigest(t *testing.T) {
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "filesystem-old.dmg", digest: []byte{0x02}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "filesystem-new.dmg", digest: []byte{0x02}},
	})
	d := &Diff{
		Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
		New: Context{InputMode: inputModeIPSW, Info: newInfo},
	}

	d.indexIdenticalIPSWArtifacts()

	if !d.dscVolumeUnchanged() {
		t.Fatal("dscVolumeUnchanged() = false, want true")
	}
}

// TestEnsureKernelcachePathsNoOpWhenSet is the regression for the partial-hit
// ordering fix: extractKernelcaches sets Kernel.Path as a side effect of the
// kexts task's Parse, but a warm kexts cache hit SKIPS that Parse, leaving the
// path empty for a sibling task (e.g. a partial-hit sandbox) that runs fresh.
// ensureKernelcachePaths backfills the path; when both sides are already set it
// must be a pure no-op (never re-extract, never touch the paths), which this test
// pins. The extraction branch needs real IPSW artifacts and is covered by the
// real-IPSW integration runs.
func TestEnsureKernelcachePathsNoOpWhenSet(t *testing.T) {
	d := &Diff{}
	d.Old.Kernel.Path = "/extracted/old/kernelcache"
	d.New.Kernel.Path = "/extracted/new/kernelcache"

	if err := d.ensureKernelcachePaths(); err != nil {
		t.Fatalf("ensureKernelcachePaths() with both paths set = %v, want nil (must be a no-op)", err)
	}
	if d.Old.Kernel.Path != "/extracted/old/kernelcache" || d.New.Kernel.Path != "/extracted/new/kernelcache" {
		t.Fatalf("ensureKernelcachePaths mutated already-set paths: old=%q new=%q",
			d.Old.Kernel.Path, d.New.Kernel.Path)
	}
}

func TestFilesSHA256Equal(t *testing.T) {
	tmpDir := t.TempDir()
	oldPath := filepath.Join(tmpDir, "old")
	newPath := filepath.Join(tmpDir, "new")

	if err := os.WriteFile(oldPath, []byte("same kernel bytes"), 0o644); err != nil {
		t.Fatalf("WriteFile(old) error = %v", err)
	}
	if err := os.WriteFile(newPath, []byte("same kernel bytes"), 0o644); err != nil {
		t.Fatalf("WriteFile(new) error = %v", err)
	}

	same, err := filesSHA256Equal(oldPath, newPath)
	if err != nil {
		t.Fatalf("filesSHA256Equal() error = %v", err)
	}
	if !same {
		t.Fatal("filesSHA256Equal() = false, want true")
	}

	if err := os.WriteFile(newPath, []byte("different kernel bytes"), 0o644); err != nil {
		t.Fatalf("WriteFile(new different) error = %v", err)
	}
	same, err = filesSHA256Equal(oldPath, newPath)
	if err != nil {
		t.Fatalf("filesSHA256Equal() after change error = %v", err)
	}
	if same {
		t.Fatal("filesSHA256Equal() = true, want false")
	}
}

func TestVolumeJobOrchestratorScansAndReleasesVolumePairs(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	oldSys := testVolumeDir(t, tmpDir, "old-sys", "old-sys-file")
	newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  oldFS,
		"sys": oldSys,
	}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  newFS,
		"sys": newSys,
	}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS":                {path: "old-fs.dmg", digest: []byte{0x01}},
		"Cryptex1,SystemOS": {path: "old-sys.dmg", digest: []byte{0x02}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS":                {path: "new-fs.dmg", digest: []byte{0x03}},
		"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x04}},
	})

	job := newRecordingFilesJob()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if got, want := oldSession.released, []string{"fs", "sys"}; !slices.Equal(got, want) {
		t.Fatalf("old released = %v, want %v", got, want)
	}
	if got, want := newSession.released, []string{"fs", "sys"}; !slices.Equal(got, want) {
		t.Fatalf("new released = %v, want %v", got, want)
	}
	if !job.finalized {
		t.Fatal("Finalize was not called")
	}
	assertFileSeen(t, job.prev, "filesystem", "old-fs-file")
	assertFileSeen(t, job.next, "filesystem", "new-fs-file")
	assertFileSeen(t, job.prev, "SystemOS", "old-sys-file")
	assertFileSeen(t, job.next, "SystemOS", "new-sys-file")
}

func TestVolumeJobOrchestratorDropsTaskWhenSetupFails(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
	})

	failing := newSetupFailingJob()
	ok := newRecordingFilesJob()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{failing, ok}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if failing.processed {
		t.Fatal("setup-failing task ran ProcessVolume; want it dropped after Setup error")
	}
	if failing.finalized {
		t.Fatal("setup-failing task was finalized; want it dropped after Setup error")
	}
	if !ok.finalized {
		t.Fatal("healthy task was not finalized; one task's setup failure must not poison siblings")
	}
	assertFileSeen(t, ok.prev, "filesystem", "old-fs-file")
}

// recordingSessionFallbackJob is a sys-only MountTask that opts into the
// session fallback and records the roots it receives.
type recordingSessionFallbackJob struct {
	gotOld, gotNew string
	processed      bool
}

func (j *recordingSessionFallbackJob) Name() string                         { return "fallback-test" }
func (j *recordingSessionFallbackJob) Needs(typ string) bool                { return typ == "sys" }
func (j *recordingSessionFallbackJob) WantsSessionFallback(typ string) bool { return typ == "sys" }
func (j *recordingSessionFallbackJob) Finalize() error                      { return nil }
func (j *recordingSessionFallbackJob) ProcessVolume(_, oldRoot, newRoot string) error {
	j.processed = true
	j.gotOld, j.gotNew = oldRoot, newRoot
	return nil
}

// recordingPlainSysJob is a sys-only MountTask WITHOUT the fallback opt-in,
// proving the fallback root is handed only to tasks that asked for it.
type recordingPlainSysJob struct {
	gotOld, gotNew string
}

func (j *recordingPlainSysJob) Name() string          { return "plain-sys-test" }
func (j *recordingPlainSysJob) Needs(typ string) bool { return typ == "sys" }
func (j *recordingPlainSysJob) Finalize() error       { return nil }
func (j *recordingPlainSysJob) ProcessVolume(_, oldRoot, newRoot string) error {
	j.gotOld, j.gotNew = oldRoot, newRoot
	return nil
}

// TestRunVolumeTasksNormalizesMountTaskRoots covers the APFS-FUSE (Linux)
// mount shape, where the mount point holds a "root" subdirectory instead of
// exposing "System" etc. directly. The Mach-O walk root already normalized via
// utils.MountedFilesystemRoot; MountTask.ProcessVolume must receive the same
// normalized root so files/features/locs/dsc/launchd key their entries
// consistently and never surface a spurious top-level "root/" volume.
func TestRunVolumeTasksNormalizesMountTaskRoots(t *testing.T) {
	tmpDir := t.TempDir()
	oldRoot := filepath.Join(tmpDir, "old")
	newRoot := filepath.Join(tmpDir, "new")
	// APFS-FUSE shape: <mount>/root/System/... with no <mount>/System.
	if err := os.MkdirAll(filepath.Join(oldRoot, "root", "System"), 0o755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(newRoot, "root", "System"), 0o755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}

	job := &recordingPlainSysJob{}
	if errs := runVolumeTasks("sys", volumeRoots{old: oldRoot, new: newRoot}, []Task{job}); len(errs) != 0 {
		t.Fatalf("runVolumeTasks() errors = %v", errs)
	}

	if want := filepath.Join(oldRoot, "root"); job.gotOld != want {
		t.Errorf("gotOld = %q, want %q", job.gotOld, want)
	}
	if want := filepath.Join(newRoot, "root"); job.gotNew != want {
		t.Errorf("gotNew = %q, want %q", job.gotNew, want)
	}
}

// TestVolumeJobOrchestratorSessionFallbackForMixedPair covers the mixed
// pre-cryptex-vs-cryptex case: the old IPSW has no SystemOS cryptex, so the
// sys phase mounts nothing for it via the strict resolver, but a
// SessionFallbackTask (dscJob in production) receives the session-resolved
// root — the real mount.Session.Root("sys") falls back to the filesystem DMG,
// simulated here by the fake session mapping "sys" to the fs dir. A plain
// task in the same phase still sees the absent side as empty.
func TestVolumeJobOrchestratorSessionFallbackForMixedPair(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  oldFS,
		"sys": oldFS, // mount.Session.Root("sys") falls back to the fs DMG
	}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  newFS,
		"sys": newSys,
	}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS":                {path: "new-fs.dmg", digest: []byte{0x02}},
		"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x03}},
	})

	fallback := &recordingSessionFallbackJob{}
	plain := &recordingPlainSysJob{}
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{fallback, plain}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if !fallback.processed {
		t.Fatal("fallback job never processed the sys volume")
	}
	if fallback.gotOld != oldFS {
		t.Fatalf("fallback job old root = %q, want session-fallback %q", fallback.gotOld, oldFS)
	}
	if fallback.gotNew != newSys {
		t.Fatalf("fallback job new root = %q, want %q", fallback.gotNew, newSys)
	}
	if plain.gotOld != "" {
		t.Fatalf("plain job old root = %q, want empty (no fallback for non-opting tasks)", plain.gotOld)
	}
	if plain.gotNew != newSys {
		t.Fatalf("plain job new root = %q, want %q", plain.gotNew, newSys)
	}
	if got, want := oldSession.released, []string{"sys"}; !slices.Equal(got, want) {
		t.Fatalf("old released = %v, want %v (fallback mount must be released)", got, want)
	}
}

func TestVolumeJobOrchestratorReleasesOnlyMountedAsymmetricVolume(t *testing.T) {
	tmpDir := t.TempDir()
	oldExc := testVolumeDir(t, tmpDir, "old-exc", "old-exc-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{
		"exc": oldExc,
	}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"Ap,ExclaveOS": {path: "old-exc.dmg", digest: []byte{0x01}},
	})
	newInfo := testIPSWInfo(nil)

	job := newRecordingFilesJob()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if got, want := oldSession.released, []string{"exc"}; !slices.Equal(got, want) {
		t.Fatalf("old released = %v, want %v", got, want)
	}
	if len(newSession.released) != 0 {
		t.Fatalf("new released = %v, want no releases", newSession.released)
	}
	assertFileSeen(t, job.prev, "ExclaveOS", "old-exc-file")
	if len(job.next) != 0 {
		t.Fatalf("new files = %v, want no files", job.next)
	}
}

func TestVolumeJobOrchestratorSkipsVolumesWithMatchingDigest(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	oldSys := testVolumeDir(t, tmpDir, "old-sys", "old-sys-file")
	newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  oldFS,
		"sys": oldSys,
	}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{
		"fs":  newFS,
		"sys": newSys,
	}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS":                {path: "old-fs.dmg", digest: []byte{0x01}},
		"Cryptex1,SystemOS": {path: "old-sys.dmg", digest: []byte{0x02}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS":                {path: "new-fs.dmg", digest: []byte{0x03}},
		"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x04}},
	})

	// Pretend fs is unchanged. Only sys should be scanned.
	unchanged := func(typ string) bool { return typ == "fs" }

	job := newRecordingFilesJob()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, unchanged, []Task{job}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if got, want := oldSession.released, []string{"sys"}; !slices.Equal(got, want) {
		t.Fatalf("old released = %v, want %v (fs should be skipped)", got, want)
	}
	if len(job.prev["filesystem"]) != 0 || len(job.next["filesystem"]) != 0 {
		t.Fatalf("fs was scanned despite digest match: prev=%v next=%v", job.prev["filesystem"], job.next["filesystem"])
	}
	assertFileSeen(t, job.prev, "SystemOS", "old-sys-file")
	assertFileSeen(t, job.next, "SystemOS", "new-sys-file")
}

func TestDSCJobOnlyNeedsSysVolume(t *testing.T) {
	j := newDSCJob(nil)
	if j.Name() != "dsc" {
		t.Fatalf("Name() = %q, want dsc", j.Name())
	}
	for _, typ := range []string{"fs", "app", "exc"} {
		if j.Needs(typ) {
			t.Errorf("dscJob.Needs(%q) = true, want false", typ)
		}
	}
	if !j.Needs("sys") {
		t.Error("dscJob.Needs(\"sys\") = false, want true")
	}
	if err := j.Finalize(); err != nil {
		t.Errorf("Finalize() = %v, want nil", err)
	}
}

func TestDSCJobFallsBackToFilesystemWhenSystemOSAbsent(t *testing.T) {
	d := &Diff{
		Old: Context{Info: testIPSWInfo(map[string]testManifestEntry{
			"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
		})},
		New: Context{Info: testIPSWInfo(map[string]testManifestEntry{
			"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
		})},
	}

	j := newDSCJob(d)
	if !j.Needs("fs") {
		t.Fatal("dscJob.Needs(\"fs\") = false, want true when SystemOS is absent")
	}
	if j.Needs("sys") {
		t.Fatal("dscJob.Needs(\"sys\") = true, want false when SystemOS is absent")
	}
	for _, typ := range []string{"app", "exc"} {
		if j.Needs(typ) {
			t.Fatalf("dscJob.Needs(%q) = true, want false", typ)
		}
	}
}

func TestLaunchdJobOnlyNeedsFsVolume(t *testing.T) {
	j := newLaunchdJob(nil)
	if j.Name() != "launchd" {
		t.Fatalf("Name() = %q, want launchd", j.Name())
	}
	for _, typ := range []string{"sys", "app", "exc"} {
		if j.Needs(typ) {
			t.Errorf("launchdJob.Needs(%q) = true, want false", typ)
		}
	}
	if !j.Needs("fs") {
		t.Error("launchdJob.Needs(\"fs\") = false, want true")
	}
	if err := j.Finalize(); err != nil {
		t.Errorf("Finalize() = %v, want nil", err)
	}
}

// recordingFilesJob is a minimal MountTask used in orchestrator tests. It
// mirrors filesJob's file-walking behavior without depending on FileDiff
// assembly so the tests focus on orchestration.
type recordingFilesJob struct {
	prev      map[string][]string
	next      map[string][]string
	finalized bool
}

func newRecordingFilesJob() *recordingFilesJob {
	return &recordingFilesJob{
		prev: make(map[string][]string),
		next: make(map[string][]string),
	}
}

func (j *recordingFilesJob) Name() string { return "files-test" }

func (j *recordingFilesJob) Needs(typ string) bool {
	switch typ {
	case "fs", "sys", "app", "exc":
		return true
	}
	return false
}

func (j *recordingFilesJob) ProcessVolume(typ, oldRoot, newRoot string) error {
	label := volumeLabel(typ)
	if oldRoot != "" {
		if err := search.ForEachFileInMount(oldRoot, label, "", func(dmg, path string) error {
			j.prev[dmg] = append(j.prev[dmg], path)
			return nil
		}); err != nil {
			return err
		}
	}
	if newRoot != "" {
		if err := search.ForEachFileInMount(newRoot, label, "", func(dmg, path string) error {
			j.next[dmg] = append(j.next[dmg], path)
			return nil
		}); err != nil {
			return err
		}
	}
	return nil
}

func (j *recordingFilesJob) Finalize() error {
	j.finalized = true
	return nil
}

// setupFailingJob is a MountTask whose Setup always errors. The orchestrator
// must drop it before any volume is processed so it never sees ProcessVolume
// or Finalize with half-initialized state.
type setupFailingJob struct {
	recordingFilesJob
	processed bool
}

func newSetupFailingJob() *setupFailingJob {
	return &setupFailingJob{recordingFilesJob: *newRecordingFilesJob()}
}

func (j *setupFailingJob) Name() string { return "setup-failing" }

func (j *setupFailingJob) Setup(storage.Store) error {
	return errors.New("setup boom")
}

func (j *setupFailingJob) ProcessVolume(typ, oldRoot, newRoot string) error {
	j.processed = true
	return j.recordingFilesJob.ProcessVolume(typ, oldRoot, newRoot)
}

// recordingMachoJob is a minimal MachoWalkTask used to verify the
// orchestrator's per-volume BeginVolume/MachoHandler/EndVolume sequencing
// without depending on real Mach-O fixtures. It records the call order so
// the test can assert structure, not heap state.
type recordingMachoJob struct {
	beginCalls []string
	endCalls   []string
	handlerOld []string
	handlerNew []string
	handlerErr error
	abortCalls []string
	oldCalls   int
	newCalls   int
	finalized  bool
}

func newRecordingMachoJob() *recordingMachoJob { return &recordingMachoJob{} }

func (j *recordingMachoJob) Name() string { return "machos-test" }

func (j *recordingMachoJob) Needs(typ string) bool {
	switch typ {
	case "fs", "sys", "app", "exc":
		return true
	}
	return false
}

func (j *recordingMachoJob) BeginVolume(typ string) error {
	j.beginCalls = append(j.beginCalls, volumeLabel(typ))
	return nil
}

func (j *recordingMachoJob) MachoHandler(typ string, side Side) MachoScanHandler {
	label := volumeLabel(typ)
	switch side {
	case SideOld:
		j.handlerOld = append(j.handlerOld, label)
	case SideNew:
		j.handlerNew = append(j.handlerNew, label)
	}
	return func(string, *macho.File) error {
		switch side {
		case SideOld:
			j.oldCalls++
		case SideNew:
			j.newCalls++
		}
		return j.handlerErr
	}
}

func (j *recordingMachoJob) EndVolume(typ string) error {
	j.endCalls = append(j.endCalls, volumeLabel(typ))
	return nil
}

func (j *recordingMachoJob) AbortVolume(typ string) {
	j.abortCalls = append(j.abortCalls, typ)
}

func (j *recordingMachoJob) Finalize() error {
	j.finalized = true
	return nil
}

func TestVolumeJobOrchestratorRunsMachoWalkTasksPerSide(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	oldSession := &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}}
	newSession := &fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "new-fs.dmg", digest: []byte{0x03}},
	})

	job := newRecordingMachoJob()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
		t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
	}

	if got, want := job.beginCalls, []string{"filesystem"}; !slices.Equal(got, want) {
		t.Errorf("beginCalls = %v, want %v", got, want)
	}
	if got, want := job.endCalls, []string{"filesystem"}; !slices.Equal(got, want) {
		t.Errorf("endCalls = %v, want %v", got, want)
	}
	if got, want := job.handlerOld, []string{"filesystem"}; !slices.Equal(got, want) {
		t.Errorf("handlerOld = %v, want %v", got, want)
	}
	if got, want := job.handlerNew, []string{"filesystem"}; !slices.Equal(got, want) {
		t.Errorf("handlerNew = %v, want %v", got, want)
	}
	if !job.finalized {
		t.Error("Finalize was not called")
	}
}

func TestRunVolumeTasksAbortsMachoTaskOnHandlerFailure(t *testing.T) {
	tmpDir := t.TempDir()
	oldRoot := filepath.Join(tmpDir, "old")
	newRoot := filepath.Join(tmpDir, "new")
	writeMinimalMachO(t, filepath.Join(oldRoot, "usr", "bin", "old-tool"))
	writeMinimalMachO(t, filepath.Join(newRoot, "usr", "bin", "new-tool"))

	want := errors.New("handler failed")
	job := newRecordingMachoJob()
	job.handlerErr = want

	errs := runVolumeTasks("fs", volumeRoots{old: oldRoot, new: newRoot}, []Task{job})
	if len(errs) != 1 {
		t.Fatalf("runVolumeTasks errors = %d, want 1 (%v)", len(errs), errs)
	}
	if !errors.Is(errs[0], want) {
		t.Fatalf("runVolumeTasks error = %v, want %v", errs[0], want)
	}
	if got, want := job.abortCalls, []string{"fs"}; !slices.Equal(got, want) {
		t.Fatalf("abortCalls = %v, want %v", got, want)
	}
	if job.oldCalls != 1 {
		t.Fatalf("oldCalls = %d, want 1", job.oldCalls)
	}
	if job.newCalls != 0 {
		t.Fatalf("newCalls = %d, want 0 after old-side abort", job.newCalls)
	}
	if len(job.endCalls) != 0 {
		t.Fatalf("endCalls = %v, want no EndVolume after abort", job.endCalls)
	}
}

func TestMachosJobAbortVolumeDropsPartialState(t *testing.T) {
	d := &Diff{conf: &Config{}}
	job := newMachosJob(d)
	job.cacheDir = t.TempDir()
	if err := job.BeginVolume("fs"); err != nil {
		t.Fatalf("BeginVolume: %v", err)
	}
	label := volumeLabel("fs")
	job.prevKeysByVolume[label]["/usr/bin/old"] = false
	job.diffByVolume[label].New = append(job.diffByVolume[label].New, "/usr/bin/new")

	job.AbortVolume("fs")

	if len(job.volumes) != 0 {
		t.Fatalf("volumes = %v, want empty after abort", job.volumes)
	}
	if _, ok := job.prevKeysByVolume[label]; ok {
		t.Fatalf("prevKeysByVolume still has %q after abort", label)
	}
	if _, ok := job.diffByVolume[label]; ok {
		t.Fatalf("diffByVolume still has %q after abort", label)
	}
	if err := job.Finalize(); err != nil {
		t.Fatalf("Finalize: %v", err)
	}
	if len(d.Machos) != 0 {
		t.Fatalf("d.Machos = %v, want no output for aborted volume", d.Machos)
	}
}

func writeMinimalMachO(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
	}
	// mach_header_64, little-endian, ARM64, MH_EXECUTE, no load commands.
	data := []byte{
		0xcf, 0xfa, 0xed, 0xfe,
		0x0c, 0x00, 0x00, 0x01,
		0x00, 0x00, 0x00, 0x00,
		0x02, 0x00, 0x00, 0x00,
		0x00, 0x00, 0x00, 0x00,
		0x00, 0x00, 0x00, 0x00,
		0x00, 0x00, 0x00, 0x00,
		0x00, 0x00, 0x00, 0x00,
	}
	if err := os.WriteFile(path, data, 0o755); err != nil {
		t.Fatalf("WriteFile(%s): %v", path, err)
	}
}

func TestSplitJoinedErrorsAndTaskNameFromError(t *testing.T) {
	a := errors.New("foo: boom")
	b := errors.New("bar: split")
	joined := errors.Join(a, b)
	parts := splitJoinedErrors(joined)
	if len(parts) != 2 {
		t.Fatalf("splitJoinedErrors: len = %d, want 2", len(parts))
	}
	if name, ok := taskNameFromError(parts[0]); !ok || name != "foo" {
		t.Errorf("taskNameFromError(parts[0]) = (%q,%v), want (foo,true)", name, ok)
	}
	if name, ok := taskNameFromError(parts[1]); !ok || name != "bar" {
		t.Errorf("taskNameFromError(parts[1]) = (%q,%v), want (bar,true)", name, ok)
	}

	if _, ok := taskNameFromError(errors.New("no prefix here")); ok {
		t.Error("taskNameFromError accepted an unprefixed error")
	}
	if _, ok := taskNameFromError(errors.New("__walk__: ouch")); ok {
		t.Error("taskNameFromError accepted the walker sentinel")
	}

	// A non-joined error round-trips as a single element so the caller can
	// still attribute walker-level failures without losing them.
	parts = splitJoinedErrors(a)
	if len(parts) != 1 || parts[0] != a {
		t.Errorf("splitJoinedErrors(single) = %v, want [%v]", parts, a)
	}
}

// fakeCacheableJob is a MountTask that also implements CacheableTask
// (including persistTo). It records the lifecycle calls the orchestrator makes
// so the cache tests can assert miss -> walk -> persist -> complete on the
// first run and hit -> hydrate -> skip-walk on the second.
type fakeCacheableJob struct {
	name       string
	version    int
	optsHash   string
	inputHash  string
	processed  int
	finalized  int
	persisted  int
	hydrated   int
	hydrateErr error
}

func (j *fakeCacheableJob) Name() string { return j.name }

func (j *fakeCacheableJob) Needs(typ string) bool { return typ == "fs" }

func (j *fakeCacheableJob) ProcessVolume(typ, oldRoot, newRoot string) error {
	j.processed++
	return nil
}

func (j *fakeCacheableJob) Finalize() error {
	j.finalized++
	return nil
}

func (j *fakeCacheableJob) Version() int        { return j.version }
func (j *fakeCacheableJob) OptionsHash() string { return j.optsHash }
func (j *fakeCacheableJob) InputHash() string   { return j.inputHash }

func (j *fakeCacheableJob) Hydrate(scope storage.Scope, store storage.Store) error {
	j.hydrated++
	return j.hydrateErr
}

func (j *fakeCacheableJob) persistTo(scope storage.Scope, store storage.Store) error {
	j.persisted++
	return store.Put(scope, "result", j.name)
}

func TestCacheLifecycleMissThenHit(t *testing.T) {
	tmpDir := t.TempDir()
	oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
	newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
	oldInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
	})
	newInfo := testIPSWInfo(map[string]testManifestEntry{
		"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
	})

	store := storage.NewMemoryStore()
	job := &fakeCacheableJob{name: "cacheable", version: 1, optsHash: "opts", inputHash: "input"}

	newSessions := func() (*fakeVolumeFileSession, *fakeVolumeFileSession) {
		return &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}},
			&fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
	}

	// First run: cache is empty, so the task misses, walks, persists, and is
	// marked complete.
	oldSession, newSession := newSessions()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, store); err != nil {
		t.Fatalf("first run error = %v", err)
	}
	if job.processed != 1 {
		t.Fatalf("first run processed = %d, want 1 (miss should walk)", job.processed)
	}
	if job.persisted != 1 {
		t.Fatalf("first run persisted = %d, want 1", job.persisted)
	}
	if job.hydrated != 0 {
		t.Fatalf("first run hydrated = %d, want 0", job.hydrated)
	}

	scope, ok := taskScope(oldInfo, newInfo, job)
	if !ok {
		t.Fatal("taskScope returned ok=false for a derivable identity")
	}
	if done, err := store.Complete(scope); err != nil || !done {
		t.Fatalf("store.Complete after first run = (%v,%v), want (true,nil)", done, err)
	}

	// Second run with the SAME store: the completion sentinel exists, so the
	// task hits, hydrates, and skips the volume walk.
	oldSession, newSession = newSessions()
	if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, store); err != nil {
		t.Fatalf("second run error = %v", err)
	}
	if job.processed != 1 {
		t.Fatalf("second run processed = %d, want 1 (hit must skip the walk)", job.processed)
	}
	if job.hydrated != 1 {
		t.Fatalf("second run hydrated = %d, want 1", job.hydrated)
	}
	if job.persisted != 1 {
		t.Fatalf("second run persisted = %d, want 1 (hit must not re-persist)", job.persisted)
	}
	if len(oldSession.released) != 0 {
		t.Fatalf("second run mounted/released old volume = %v, want none (hydrated task skips mount)", oldSession.released)
	}
}

func TestCacheLifecycleUnderivableIdentityRunsFresh(t *testing.T) {
	// Info structs without a BuildManifest: identity is underivable, so the
	// task is treated as non-cacheable for the run. (Volume resolution also
	// requires a BuildManifest, so the underivable decision is made before
	// any walk could happen.)
	oldInfo := &info.Info{}
	newInfo := &info.Info{}

	job := &fakeCacheableJob{name: "cacheable", version: 1, optsHash: "opts", inputHash: "input"}
	if _, ok := taskScope(oldInfo, newInfo, job); ok {
		t.Fatal("taskScope returned ok=true for an underivable identity")
	}

	store := storage.NewMemoryStore()
	lc := newCacheLifecycle(oldInfo, newInfo, []Task{job}, store)
	if lc.isHydrated(job) {
		t.Fatal("underivable task was hydrated")
	}
	if len(lc.scopes) != 0 {
		t.Fatalf("underivable task recorded a scope: %v", lc.scopes)
	}

	// persistAndComplete must be a no-op for the underivable task: it has no
	// scope, so it is never persisted nor marked complete.
	lc.persistAndComplete(store)
	if job.persisted != 0 {
		t.Fatalf("underivable task persisted = %d, want 0", job.persisted)
	}
}

type testManifestEntry struct {
	path   string
	digest []byte
}

func testIPSWInfo(entries map[string]testManifestEntry) *info.Info {
	manifest := make(map[string]plist.IdentityManifest, len(entries))
	for key, entry := range entries {
		manifest[key] = plist.IdentityManifest{
			Digest: entry.digest,
			Info: map[string]any{
				"Path": entry.path,
			},
		}
	}
	return &info.Info{
		Plists: &plist.Plists{
			BuildManifest: &plist.BuildManifest{
				BuildIdentities: []plist.BuildIdentity{
					{
						Info: plist.IdentityInfo{
							DeviceClass: "v53",
							Variant:     "Customer Erase Install",
						},
						Manifest: manifest,
					},
				},
			},
		},
	}
}

func testVolumeDir(t *testing.T, tmpDir, name, fileName string) string {
	t.Helper()
	dir := filepath.Join(tmpDir, name)
	if err := os.MkdirAll(filepath.Join(dir, "System"), 0o755); err != nil {
		t.Fatalf("MkdirAll(%s): %v", dir, err)
	}
	path := filepath.Join(dir, "System", fileName)
	if err := os.WriteFile(path, []byte(fileName), 0o644); err != nil {
		t.Fatalf("WriteFile(%s): %v", path, err)
	}
	return dir
}

func assertFileSeen(t *testing.T, files map[string][]string, dmg, want string) {
	t.Helper()
	if !strings.Contains(strings.Join(files[dmg], ","), want) {
		t.Fatalf("%s files = %v, want %s", dmg, files[dmg], want)
	}
}
