package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io/fs"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
	"time"
)

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------

// writeTree materialises a map of slash-relative path -> content under root.
func writeTree(t *testing.T, root string, files map[string]string) {
	t.Helper()
	if err := os.MkdirAll(root, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", root, err)
	}
	for p, body := range files {
		full := filepath.Join(root, filepath.FromSlash(p))
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", filepath.Dir(full), err)
		}
		if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
			t.Fatalf("write %s: %v", full, err)
		}
	}
}

// treeState reads a tree into a comparable map of path -> "mode:sha256:size".
type stateEntry struct {
	Mode os.FileMode
	Hash string
	Size int64
}

func treeState(t *testing.T, root string) map[string]stateEntry {
	t.Helper()
	out := map[string]stateEntry{}
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || !d.Type().IsRegular() {
			return nil
		}
		rel, err := filepath.Rel(root, p)
		if err != nil {
			return err
		}
		body, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		sum := sha256.Sum256(body)
		out[filepath.ToSlash(rel)] = stateEntry{
			Mode: info.Mode().Perm(),
			Hash: hex.EncodeToString(sum[:]),
			Size: info.Size(),
		}
		return nil
	})
	if err != nil {
		t.Fatalf("walk %s: %v", root, err)
	}
	return out
}

func readFile(t *testing.T, p string) string {
	t.Helper()
	b, err := os.ReadFile(p)
	if err != nil {
		t.Fatalf("read %s: %v", p, err)
	}
	return string(b)
}

func mustStore(t *testing.T, root string) *Store {
	t.Helper()
	s, err := openStore(root)
	if err != nil {
		t.Fatalf("openStore: %v", err)
	}
	return s
}

// snap takes a snapshot and fails the test on any error.
func snap(t *testing.T, s *Store, source, name string, at time.Time) *Manifest {
	t.Helper()
	m, _, err := takeSnapshot(s, source, name, at)
	if err != nil {
		t.Fatalf("takeSnapshot(%s): %v", source, err)
	}
	return m
}

func countObjects(t *testing.T, s *Store) int {
	t.Helper()
	n := 0
	err := filepath.WalkDir(s.objectsDir(), func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			if os.IsNotExist(err) {
				return nil
			}
			return err
		}
		if !d.IsDir() {
			n++
		}
		return nil
	})
	if err != nil {
		t.Fatalf("walk objects: %v", err)
	}
	return n
}

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

var t0 = time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC)

// ---------------------------------------------------------------------------
// Small pure helpers
// ---------------------------------------------------------------------------

func TestHumanBytes(t *testing.T) {
	cases := []struct {
		in   int64
		want string
	}{
		{0, "0 B"},
		{1, "1 B"},
		{1023, "1023 B"},
		{1024, "1.0 KiB"},
		{1536, "1.5 KiB"},
		{1024 * 1024, "1.0 MiB"},
		{3 * 1024 * 1024 * 1024, "3.0 GiB"},
		{-2048, "-2.0 KiB"},
	}
	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 TestReorderFlags(t *testing.T) {
	cases := []struct {
		name string
		in   []string
		want []string
	}{
		{
			name: "flags already first",
			in:   []string{"--store", "s", "snapA"},
			want: []string{"--store", "s", "snapA"},
		},
		{
			name: "flags after positionals",
			in:   []string{"snapA", "--store", "s", "--json"},
			want: []string{"--store", "s", "--json", "snapA"},
		},
		{
			name: "interleaved with two positionals",
			in:   []string{"snapA", "--store", "s", "snapB", "--json"},
			want: []string{"--store", "s", "--json", "snapA", "snapB"},
		},
		{
			name: "bool flag between positionals",
			in:   []string{"snapA", "--apply", "--target", "t"},
			want: []string{"--apply", "--target", "t", "snapA"},
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := reorderFlags(c.in, valueFlags)
			if !reflect.DeepEqual(got, c.want) {
				t.Errorf("reorderFlags(%v) = %v, want %v", c.in, got, c.want)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Deduplication
// ---------------------------------------------------------------------------

func TestSnapshotDedup(t *testing.T) {
	cases := []struct {
		name string
		// second tree written before the second snapshot
		first  map[string]string
		second map[string]string

		wantFirstFiles  int
		wantFirstUnique int
		wantFirstAdded  int64

		wantSecondFiles  int
		wantSecondUnique int
		wantSecondAdded  int64

		wantObjectsAfter int
	}{
		{
			name:   "same blob twice in one tree is stored once",
			first:  map[string]string{"a/x.sys": "IDENTICAL", "b/y.sys": "IDENTICAL"},
			second: map[string]string{"a/x.sys": "IDENTICAL", "b/y.sys": "IDENTICAL"},

			wantFirstFiles:  2,
			wantFirstUnique: 1,
			wantFirstAdded:  int64(len("IDENTICAL")),

			wantSecondFiles:  2,
			wantSecondUnique: 1,
			wantSecondAdded:  0,

			wantObjectsAfter: 1,
		},
		{
			name:   "identical second snapshot adds nothing",
			first:  map[string]string{"one.inf": "AAA", "two.inf": "BBBB"},
			second: map[string]string{"one.inf": "AAA", "two.inf": "BBBB"},

			wantFirstFiles:  2,
			wantFirstUnique: 2,
			wantFirstAdded:  7,

			wantSecondFiles:  2,
			wantSecondUnique: 2,
			wantSecondAdded:  0,

			wantObjectsAfter: 2,
		},
		{
			name:   "only the new blob is added the second time",
			first:  map[string]string{"one.inf": "AAA"},
			second: map[string]string{"one.inf": "AAA", "two.inf": "BBBB"},

			wantFirstFiles:  1,
			wantFirstUnique: 1,
			wantFirstAdded:  3,

			wantSecondFiles:  2,
			wantSecondUnique: 2,
			wantSecondAdded:  4,

			wantObjectsAfter: 2,
		},
		{
			name:   "a renamed file reuses the existing blob",
			first:  map[string]string{"old/name.sys": "PAYLOAD"},
			second: map[string]string{"new/name.sys": "PAYLOAD"},

			wantFirstFiles:  1,
			wantFirstUnique: 1,
			wantFirstAdded:  7,

			wantSecondFiles:  1,
			wantSecondUnique: 1,
			wantSecondAdded:  0,

			wantObjectsAfter: 1,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			dir := t.TempDir()
			src := filepath.Join(dir, "driverstore")
			s := mustStore(t, filepath.Join(dir, "store"))

			writeTree(t, src, c.first)
			m1 := snap(t, s, src, "first", t0)
			if m1.FileCount != c.wantFirstFiles {
				t.Errorf("first FileCount = %d, want %d", m1.FileCount, c.wantFirstFiles)
			}
			if m1.UniqueObjects != c.wantFirstUnique {
				t.Errorf("first UniqueObjects = %d, want %d", m1.UniqueObjects, c.wantFirstUnique)
			}
			if m1.AddedBytes != c.wantFirstAdded {
				t.Errorf("first AddedBytes = %d, want %d", m1.AddedBytes, c.wantFirstAdded)
			}

			if err := os.RemoveAll(src); err != nil {
				t.Fatal(err)
			}
			writeTree(t, src, c.second)
			m2 := snap(t, s, src, "second", t0.Add(time.Minute))
			if m2.FileCount != c.wantSecondFiles {
				t.Errorf("second FileCount = %d, want %d", m2.FileCount, c.wantSecondFiles)
			}
			if m2.UniqueObjects != c.wantSecondUnique {
				t.Errorf("second UniqueObjects = %d, want %d", m2.UniqueObjects, c.wantSecondUnique)
			}
			if m2.AddedBytes != c.wantSecondAdded {
				t.Errorf("second AddedBytes = %d, want %d (dedup did not happen)", m2.AddedBytes, c.wantSecondAdded)
			}
			if n := countObjects(t, s); n != c.wantObjectsAfter {
				t.Errorf("objects on disk = %d, want %d", n, c.wantObjectsAfter)
			}
		})
	}
}

func TestSnapshotDedupStoresBlobBytesOnce(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "driverstore")
	s := mustStore(t, filepath.Join(dir, "store"))
	body := strings.Repeat("X", 4096)
	writeTree(t, src, map[string]string{"a.sys": body, "b/c.sys": body, "b/d.sys": body})

	m := snap(t, s, src, "", t0)
	if m.ApparentBytes != 3*4096 {
		t.Fatalf("ApparentBytes = %d, want %d", m.ApparentBytes, 3*4096)
	}
	if m.AddedBytes != 4096 {
		t.Fatalf("AddedBytes = %d, want 4096 - identical blob was stored more than once", m.AddedBytes)
	}
	obj := s.objectPath(sha(body))
	info, err := os.Stat(obj)
	if err != nil {
		t.Fatalf("object not stored: %v", err)
	}
	if info.Size() != 4096 {
		t.Fatalf("object size = %d, want 4096", info.Size())
	}
	if n := countObjects(t, s); n != 1 {
		t.Fatalf("objects on disk = %d, want 1", n)
	}
}

func TestSnapshotWritesLedgerAndManifest(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "driverstore")
	s := mustStore(t, filepath.Join(dir, "store"))
	writeTree(t, src, map[string]string{"a.sys": "hello"})

	m, audit, err := takeSnapshot(s, src, "labelled", t0)
	if err != nil {
		t.Fatal(err)
	}
	if len(audit) != 1 || audit[0].Path != "a.sys" || audit[0].Action != "store" {
		t.Fatalf("audit = %+v, want one stored a.sys", audit)
	}
	lp := filepath.Join(s.Root, "ledger.jsonl")
	if err := appendLedger(lp, AuditRecord{TS: t0, Op: "snapshot", Snapshot: m.ID, Files: audit}); err != nil {
		t.Fatal(err)
	}
	var rec AuditRecord
	if err := json.Unmarshal([]byte(strings.TrimSpace(readFile(t, lp))), &rec); err != nil {
		t.Fatalf("ledger line is not valid JSON: %v", err)
	}
	if rec.Snapshot != m.ID || rec.Op != "snapshot" {
		t.Fatalf("ledger record = %+v", rec)
	}

	got, err := s.loadManifest(m.ID)
	if err != nil {
		t.Fatalf("loadManifest: %v", err)
	}
	if got.Files[0].Hash != sha("hello") {
		t.Fatalf("manifest hash = %s, want %s", got.Files[0].Hash, sha("hello"))
	}
	if got.Name != "labelled" {
		t.Fatalf("manifest name = %q", got.Name)
	}
}

// ---------------------------------------------------------------------------
// diff
// ---------------------------------------------------------------------------

func TestDiffSnapshots(t *testing.T) {
	type want struct {
		added, removed, changed, unchanged int
		bytesAdded, bytesRemoved, delta    int64
		changes                            map[string]string // path -> change
	}
	cases := []struct {
		name  string
		treeA map[string]string
		treeB map[string]string
		want  want
	}{
		{
			name:  "identical trees",
			treeA: map[string]string{"a.sys": "AAA", "b.inf": "BB"},
			treeB: map[string]string{"a.sys": "AAA", "b.inf": "BB"},
			want: want{
				unchanged: 2,
				changes:   map[string]string{"a.sys": "unchanged", "b.inf": "unchanged"},
			},
		},
		{
			name:  "pure addition",
			treeA: map[string]string{"a.sys": "AAA"},
			treeB: map[string]string{"a.sys": "AAA", "new/c.sys": "CCCCC"},
			want: want{
				added: 1, unchanged: 1, bytesAdded: 5, delta: 5,
				changes: map[string]string{"a.sys": "unchanged", "new/c.sys": "added"},
			},
		},
		{
			name:  "pure removal",
			treeA: map[string]string{"a.sys": "AAA", "gone.sys": "GGGG"},
			treeB: map[string]string{"a.sys": "AAA"},
			want: want{
				removed: 1, unchanged: 1, bytesRemoved: 4, delta: -4,
				changes: map[string]string{"a.sys": "unchanged", "gone.sys": "removed"},
			},
		},
		{
			name:  "content change same size",
			treeA: map[string]string{"a.sys": "AAA"},
			treeB: map[string]string{"a.sys": "ZZZ"},
			want: want{
				changed: 1, delta: 0,
				changes: map[string]string{"a.sys": "changed"},
			},
		},
		{
			name:  "content change growing",
			treeA: map[string]string{"a.sys": "AAA"},
			treeB: map[string]string{"a.sys": "AAAAAAA"},
			want: want{
				changed: 1, delta: 4,
				changes: map[string]string{"a.sys": "changed"},
			},
		},
		{
			name:  "add remove change and keep at once",
			treeA: map[string]string{"keep.sys": "K", "chg.sys": "OLD", "del.sys": "DDDD"},
			treeB: map[string]string{"keep.sys": "K", "chg.sys": "NEWER", "add.sys": "AA"},
			want: want{
				added: 1, removed: 1, changed: 1, unchanged: 1,
				bytesAdded: 2, bytesRemoved: 4, delta: 0,
				changes: map[string]string{
					"add.sys": "added", "chg.sys": "changed",
					"del.sys": "removed", "keep.sys": "unchanged",
				},
			},
		},
		{
			name:  "moved file is a remove plus an add",
			treeA: map[string]string{"old/p.sys": "SAME"},
			treeB: map[string]string{"new/p.sys": "SAME"},
			want: want{
				added: 1, removed: 1, bytesAdded: 4, bytesRemoved: 4, delta: 0,
				changes: map[string]string{"new/p.sys": "added", "old/p.sys": "removed"},
			},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			dir := t.TempDir()
			s := mustStore(t, filepath.Join(dir, "store"))
			a := filepath.Join(dir, "a")
			b := filepath.Join(dir, "b")
			writeTree(t, a, c.treeA)
			writeTree(t, b, c.treeB)
			ma := snap(t, s, a, "A", t0)
			mb := snap(t, s, b, "B", t0.Add(time.Minute))

			r := diffSnapshots(ma, mb)
			if r.Added != c.want.added || r.Removed != c.want.removed ||
				r.Changed != c.want.changed || r.Unchanged != c.want.unchanged {
				t.Errorf("counts: added=%d removed=%d changed=%d unchanged=%d; want %d/%d/%d/%d",
					r.Added, r.Removed, r.Changed, r.Unchanged,
					c.want.added, c.want.removed, c.want.changed, c.want.unchanged)
			}
			if r.BytesAdded != c.want.bytesAdded {
				t.Errorf("BytesAdded = %d, want %d", r.BytesAdded, c.want.bytesAdded)
			}
			if r.BytesRemoved != c.want.bytesRemoved {
				t.Errorf("BytesRemoved = %d, want %d", r.BytesRemoved, c.want.bytesRemoved)
			}
			if r.BytesDelta != c.want.delta {
				t.Errorf("BytesDelta = %d, want %d", r.BytesDelta, c.want.delta)
			}

			got := map[string]string{}
			var order []string
			for _, e := range r.Entries {
				got[e.Path] = e.Change
				order = append(order, e.Path)
			}
			if !reflect.DeepEqual(got, c.want.changes) {
				t.Errorf("entries = %v, want %v", got, c.want.changes)
			}
			if !sort.StringsAreSorted(order) {
				t.Errorf("diff entries are not sorted by path: %v", order)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// restore planning
// ---------------------------------------------------------------------------

func TestPlanRestore(t *testing.T) {
	cases := []struct {
		name     string
		snapTree map[string]string
		nowTree  map[string]string
		wantOps  map[string]string // path -> op
		create   int
		over     int
		remove   int
		keep     int
	}{
		{
			name:     "target already matches",
			snapTree: map[string]string{"a.sys": "AAA", "d/b.inf": "BB"},
			nowTree:  map[string]string{"a.sys": "AAA", "d/b.inf": "BB"},
			wantOps:  map[string]string{"a.sys": opKeep, "d/b.inf": opKeep},
			keep:     2,
		},
		{
			name:     "missing file is created",
			snapTree: map[string]string{"a.sys": "AAA", "d/b.inf": "BB"},
			nowTree:  map[string]string{"a.sys": "AAA"},
			wantOps:  map[string]string{"a.sys": opKeep, "d/b.inf": opCreate},
			create:   1, keep: 1,
		},
		{
			name:     "modified file is overwritten",
			snapTree: map[string]string{"a.sys": "AAA"},
			nowTree:  map[string]string{"a.sys": "TAMPERED"},
			wantOps:  map[string]string{"a.sys": opOverwrite},
			over:     1,
		},
		{
			name:     "extra file is quarantined",
			snapTree: map[string]string{"a.sys": "AAA"},
			nowTree:  map[string]string{"a.sys": "AAA", "rogue.sys": "ROGUE"},
			wantOps:  map[string]string{"a.sys": opKeep, "rogue.sys": opRemove},
			remove:   1, keep: 1,
		},
		{
			name:     "empty target creates everything",
			snapTree: map[string]string{"a.sys": "AAA", "x/y/z.sys": "Z"},
			nowTree:  map[string]string{},
			wantOps:  map[string]string{"a.sys": opCreate, "x/y/z.sys": opCreate},
			create:   2,
		},
		{
			name:     "all four operations at once",
			snapTree: map[string]string{"keep.sys": "K", "chg.sys": "OLD", "gone.sys": "G"},
			nowTree:  map[string]string{"keep.sys": "K", "chg.sys": "NEW", "extra.sys": "E"},
			wantOps: map[string]string{
				"keep.sys": opKeep, "chg.sys": opOverwrite,
				"gone.sys": opCreate, "extra.sys": opRemove,
			},
			create: 1, over: 1, remove: 1, keep: 1,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			dir := t.TempDir()
			s := mustStore(t, filepath.Join(dir, "store"))
			src := filepath.Join(dir, "snapsrc")
			writeTree(t, src, c.snapTree)
			m := snap(t, s, src, "", t0)

			tgt := filepath.Join(dir, "target")
			writeTree(t, tgt, c.nowTree)

			plan, err := planRestore(s, m, tgt)
			if err != nil {
				t.Fatalf("planRestore: %v", err)
			}
			got := map[string]string{}
			var order []string
			for _, o := range plan.Ops {
				got[o.Path] = o.Op
				order = append(order, o.Path)
			}
			if !reflect.DeepEqual(got, c.wantOps) {
				t.Errorf("ops = %v, want %v", got, c.wantOps)
			}
			if !sort.StringsAreSorted(order) {
				t.Errorf("plan ops are not sorted by path: %v", order)
			}
			if plan.Create != c.create || plan.Overwrite != c.over ||
				plan.Remove != c.remove || plan.Keep != c.keep {
				t.Errorf("counts create=%d overwrite=%d remove=%d keep=%d; want %d/%d/%d/%d",
					plan.Create, plan.Overwrite, plan.Remove, plan.Keep,
					c.create, c.over, c.remove, c.keep)
			}
			if plan.Mutations() != c.create+c.over+c.remove {
				t.Errorf("Mutations() = %d, want %d", plan.Mutations(), c.create+c.over+c.remove)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// dry run must not touch anything
// ---------------------------------------------------------------------------

func TestDryRunLeavesTargetByteIdentical(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "snapsrc")
	writeTree(t, src, map[string]string{"a.sys": "AAA", "sub/b.inf": "BBBB", "gone.sys": "G"})
	m := snap(t, s, src, "", t0)

	tgt := filepath.Join(dir, "target")
	writeTree(t, tgt, map[string]string{"a.sys": "TAMPERED", "sub/b.inf": "BBBB", "rogue.sys": "R"})
	before := treeState(t, tgt)

	plan, err := planRestore(s, m, tgt)
	if err != nil {
		t.Fatalf("planRestore: %v", err)
	}
	if plan.Mutations() == 0 {
		t.Fatalf("test is vacuous: the plan has nothing to do")
	}

	after := treeState(t, tgt)
	if !reflect.DeepEqual(before, after) {
		t.Errorf("dry run modified the target:\nbefore = %v\nafter  = %v", before, after)
	}
	// A dry run must not create the quarantine directory either.
	q := defaultQuarantine(plan.Target, t0)
	if _, err := os.Stat(filepath.Dir(q)); err == nil {
		t.Errorf("dry run created a quarantine directory at %s", filepath.Dir(q))
	}
}

// ---------------------------------------------------------------------------
// apply
// ---------------------------------------------------------------------------

func TestApplyRestoresExactlyAndQuarantines(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "snapsrc")
	snapTree := map[string]string{
		"a.sys":      "ORIGINAL-A",
		"sub/b.inf":  "ORIGINAL-B",
		"sub/c.cat":  "ORIGINAL-C",
		"deep/d.sys": "ORIGINAL-D",
	}
	writeTree(t, src, snapTree)
	m := snap(t, s, src, "pre-update", t0)
	wantState := treeState(t, src)

	tgt := filepath.Join(dir, "target")
	writeTree(t, tgt, map[string]string{
		"a.sys":     "TAMPERED-A", // overwrite
		"sub/b.inf": "ORIGINAL-B", // keep
		"sub/c.cat": "ORIGINAL-C", // keep
		"rogue.sys": "ROGUE",      // quarantine
		// deep/d.sys is missing -> create
	})

	plan, err := planRestore(s, m, tgt)
	if err != nil {
		t.Fatal(err)
	}
	if plan.Create != 1 || plan.Overwrite != 1 || plan.Remove != 1 || plan.Keep != 2 {
		t.Fatalf("unexpected plan: %+v", plan)
	}

	qroot := filepath.Join(dir, "quarantine", "run1")
	audit, err := applyRestore(s, m, plan, qroot)
	if err != nil {
		t.Fatalf("applyRestore: %v", err)
	}

	gotState := treeState(t, tgt)
	if !reflect.DeepEqual(gotState, wantState) {
		t.Errorf("restored tree does not match the snapshot:\ngot  = %v\nwant = %v", gotState, wantState)
	}

	// Displaced files were MOVED, not deleted.
	if got := readFile(t, filepath.Join(qroot, "a.sys")); got != "TAMPERED-A" {
		t.Errorf("quarantined a.sys = %q, want %q", got, "TAMPERED-A")
	}
	if got := readFile(t, filepath.Join(qroot, "rogue.sys")); got != "ROGUE" {
		t.Errorf("quarantined rogue.sys = %q, want %q", got, "ROGUE")
	}
	// The rogue file is gone from the target.
	if _, err := os.Stat(filepath.Join(tgt, "rogue.sys")); !os.IsNotExist(err) {
		t.Errorf("rogue.sys is still in the target (err=%v)", err)
	}
	// No .part leftovers.
	for p := range gotState {
		if strings.HasSuffix(p, ".part") {
			t.Errorf("leftover temp file in the target: %s", p)
		}
	}

	outcomes := map[string]string{}
	for _, a := range audit {
		outcomes[a.Path] = a.Action + "/" + a.Status
	}
	want := map[string]string{
		"a.sys":      "overwrite/restored+quarantined",
		"deep/d.sys": "create/restored",
		"rogue.sys":  "remove/quarantined",
		"sub/b.inf":  "keep/ok",
		"sub/c.cat":  "keep/ok",
	}
	if !reflect.DeepEqual(outcomes, want) {
		t.Errorf("audit outcomes = %v, want %v", outcomes, want)
	}

	// A second plan against the restored tree must be a no-op.
	plan2, err := planRestore(s, m, tgt)
	if err != nil {
		t.Fatal(err)
	}
	if plan2.Mutations() != 0 {
		t.Errorf("restore is not idempotent: second plan still has %d mutations (%+v)", plan2.Mutations(), plan2.Ops)
	}
}

func TestApplyRefusesDirectoryConflict(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "snapsrc")
	writeTree(t, src, map[string]string{"a.sys": "A"})
	m := snap(t, s, src, "", t0)

	tgt := filepath.Join(dir, "target")
	if err := os.MkdirAll(filepath.Join(tgt, "a.sys"), 0o755); err != nil {
		t.Fatal(err)
	}
	plan, err := planRestore(s, m, tgt)
	if err != nil {
		t.Fatal(err)
	}
	if plan.Conflict != 1 {
		t.Fatalf("Conflict = %d, want 1 (ops=%+v)", plan.Conflict, plan.Ops)
	}
	if _, err := applyRestore(s, m, plan, filepath.Join(dir, "q")); err == nil {
		t.Fatal("applyRestore accepted a directory conflict; it must refuse")
	}
	if _, err := os.Stat(filepath.Join(tgt, "a.sys")); err != nil {
		t.Errorf("the conflicting directory was disturbed: %v", err)
	}
}

func TestApplyRefusesMissingObject(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "snapsrc")
	writeTree(t, src, map[string]string{"a.sys": "A-BODY"})
	m := snap(t, s, src, "", t0)

	if err := os.Remove(s.objectPath(sha("A-BODY"))); err != nil {
		t.Fatal(err)
	}
	tgt := filepath.Join(dir, "target")
	writeTree(t, tgt, map[string]string{})
	plan, err := planRestore(s, m, tgt)
	if err != nil {
		t.Fatal(err)
	}
	_, err = applyRestore(s, m, plan, filepath.Join(dir, "q"))
	if err == nil {
		t.Fatal("applyRestore succeeded with a missing object")
	}
	if !strings.Contains(err.Error(), "missing object") {
		t.Errorf("error = %v, want a missing-object error", err)
	}
	if _, statErr := os.Stat(filepath.Join(tgt, "a.sys")); !os.IsNotExist(statErr) {
		t.Errorf("a partial restore happened despite the missing object")
	}
}

// ---------------------------------------------------------------------------
// hash mismatch on restore
// ---------------------------------------------------------------------------

func TestApplyDetectsHashMismatch(t *testing.T) {
	cases := []struct {
		name    string
		corrupt string // replacement bytes for the stored object
	}{
		{name: "same length, different bytes", corrupt: "XXXXXX"},
		{name: "different length", corrupt: "SHORT"},
		{name: "truncated to empty", corrupt: ""},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			dir := t.TempDir()
			s := mustStore(t, filepath.Join(dir, "store"))
			src := filepath.Join(dir, "snapsrc")
			writeTree(t, src, map[string]string{"a.sys": "A-BODY"})
			m := snap(t, s, src, "", t0)

			obj := s.objectPath(sha("A-BODY"))
			if err := os.Chmod(obj, 0o644); err != nil {
				t.Fatal(err)
			}
			if err := os.WriteFile(obj, []byte(c.corrupt), 0o644); err != nil {
				t.Fatal(err)
			}

			tgt := filepath.Join(dir, "target")
			writeTree(t, tgt, map[string]string{})
			plan, err := planRestore(s, m, tgt)
			if err != nil {
				t.Fatal(err)
			}
			_, err = applyRestore(s, m, plan, filepath.Join(dir, "q"))
			if err == nil {
				t.Fatal("applyRestore accepted a corrupt object; the re-hash check did not fire")
			}
			if !strings.Contains(err.Error(), "does not match the manifest") {
				t.Errorf("error = %v, want a manifest-mismatch error", err)
			}
			// The bad content must not be left lying in the target.
			if _, err := os.Stat(filepath.Join(tgt, "a.sys")); !os.IsNotExist(err) {
				t.Errorf("corrupt content was published into the target")
			}
			if _, err := os.Stat(filepath.Join(tgt, "a.sys.part")); !os.IsNotExist(err) {
				t.Errorf("a .part temp file was left behind")
			}
		})
	}
}

// ---------------------------------------------------------------------------
// verify
// ---------------------------------------------------------------------------

func TestVerifySnapshot(t *testing.T) {
	const bodyA = "ALPHA-BODY"
	const bodyB = "BRAVO-BODY"

	cases := []struct {
		name string
		// damage is applied to the store after the snapshot is taken
		damage         func(t *testing.T, s *Store)
		wantOK         int
		wantMissing    int
		wantCorrupt    int
		wantHealthy    bool
		wantStatusOfA  string
		wantBytesCheck int64
	}{
		{
			name:           "intact store",
			damage:         func(t *testing.T, s *Store) {},
			wantOK:         2,
			wantHealthy:    true,
			wantStatusOfA:  "ok",
			wantBytesCheck: int64(len(bodyA) + len(bodyB)),
		},
		{
			name: "missing object",
			damage: func(t *testing.T, s *Store) {
				if err := os.Remove(s.objectPath(sha(bodyA))); err != nil {
					t.Fatal(err)
				}
			},
			wantOK:         1,
			wantMissing:    1,
			wantStatusOfA:  "missing",
			wantBytesCheck: int64(len(bodyB)),
		},
		{
			name: "corrupt object, same length",
			damage: func(t *testing.T, s *Store) {
				p := s.objectPath(sha(bodyA))
				if err := os.Chmod(p, 0o644); err != nil {
					t.Fatal(err)
				}
				if err := os.WriteFile(p, []byte("CORRUPTBOD"), 0o644); err != nil {
					t.Fatal(err)
				}
			},
			wantOK:         1,
			wantCorrupt:    1,
			wantStatusOfA:  "corrupt",
			wantBytesCheck: int64(len(bodyB)),
		},
		{
			name: "corrupt object, truncated",
			damage: func(t *testing.T, s *Store) {
				p := s.objectPath(sha(bodyA))
				if err := os.Chmod(p, 0o644); err != nil {
					t.Fatal(err)
				}
				if err := os.WriteFile(p, []byte("X"), 0o644); err != nil {
					t.Fatal(err)
				}
			},
			wantOK:         1,
			wantCorrupt:    1,
			wantStatusOfA:  "corrupt",
			wantBytesCheck: int64(len(bodyB)),
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			dir := t.TempDir()
			s := mustStore(t, filepath.Join(dir, "store"))
			src := filepath.Join(dir, "snapsrc")
			writeTree(t, src, map[string]string{"alpha.sys": bodyA, "bravo.sys": bodyB})
			m := snap(t, s, src, "", t0)

			c.damage(t, s)

			r := verifySnapshot(s, m)
			if r.Objects != 2 {
				t.Fatalf("Objects = %d, want 2", r.Objects)
			}
			if r.OK != c.wantOK || r.Missing != c.wantMissing || r.Corrupt != c.wantCorrupt {
				t.Errorf("ok=%d missing=%d corrupt=%d; want %d/%d/%d",
					r.OK, r.Missing, r.Corrupt, c.wantOK, c.wantMissing, c.wantCorrupt)
			}
			if r.Healthy != c.wantHealthy {
				t.Errorf("Healthy = %v, want %v", r.Healthy, c.wantHealthy)
			}
			if r.Bytes != c.wantBytesCheck {
				t.Errorf("Bytes = %d, want %d", r.Bytes, c.wantBytesCheck)
			}
			var statusOfA string
			for _, o := range r.Results {
				if o.Hash == sha(bodyA) {
					statusOfA = o.Status
					if len(o.Paths) != 1 || o.Paths[0] != "alpha.sys" {
						t.Errorf("paths for alpha = %v", o.Paths)
					}
				}
			}
			if statusOfA != c.wantStatusOfA {
				t.Errorf("status of alpha.sys object = %q, want %q", statusOfA, c.wantStatusOfA)
			}
		})
	}
}

func TestVerifyGroupsSharedObjects(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "snapsrc")
	writeTree(t, src, map[string]string{"a.sys": "SHARED", "b/c.sys": "SHARED"})
	m := snap(t, s, src, "", t0)

	r := verifySnapshot(s, m)
	if r.Objects != 1 {
		t.Fatalf("Objects = %d, want 1 (two files share one blob)", r.Objects)
	}
	if got := r.Results[0].Paths; !reflect.DeepEqual(got, []string{"a.sys", "b/c.sys"}) {
		t.Errorf("paths = %v, want [a.sys b/c.sys]", got)
	}
	if !r.Healthy {
		t.Errorf("intact store reported unhealthy")
	}
}

// ---------------------------------------------------------------------------
// store bookkeeping
// ---------------------------------------------------------------------------

func TestStoreExcludesItselfFromSnapshots(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "driverstore")
	writeTree(t, src, map[string]string{"a.sys": "A"})
	s := mustStore(t, filepath.Join(src, ".store")) // store lives INSIDE the source

	m1 := snap(t, s, src, "", t0)
	m2 := snap(t, s, src, "", t0.Add(time.Minute))
	if m1.FileCount != 1 || m2.FileCount != 1 {
		t.Fatalf("file counts = %d, %d; want 1, 1 - the store snapshotted itself", m1.FileCount, m2.FileCount)
	}
	if m2.AddedBytes != 0 {
		t.Fatalf("second snapshot added %d bytes, want 0", m2.AddedBytes)
	}
}

func TestListManifestsIsChronological(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "src")
	writeTree(t, src, map[string]string{"a.sys": "A"})
	want := []string{}
	for i := 0; i < 3; i++ {
		m := snap(t, s, src, "", t0.Add(time.Duration(i)*time.Hour))
		want = append(want, m.ID)
	}
	ms, err := s.listManifests()
	if err != nil {
		t.Fatal(err)
	}
	var got []string
	for _, m := range ms {
		got = append(got, m.ID)
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("list order = %v, want %v", got, want)
	}
}

func TestSnapshotIDCollisionsGetSuffixes(t *testing.T) {
	dir := t.TempDir()
	s := mustStore(t, filepath.Join(dir, "store"))
	src := filepath.Join(dir, "src")
	writeTree(t, src, map[string]string{"a.sys": "A"})
	a := snap(t, s, src, "", t0)
	b := snap(t, s, src, "", t0) // same second
	if a.ID == b.ID {
		t.Fatalf("two snapshots in the same second share the id %q", a.ID)
	}
	if !strings.HasPrefix(b.ID, a.ID) {
		t.Errorf("second id %q is not derived from %q", b.ID, a.ID)
	}
}
