// Content-addressed snapshot engine for DriverRollback.
//
// The store is a plain directory:
//
//	<store>/objects/aa/bbbb...   one file per unique SHA-256 blob
//	<store>/snapshots/<id>.json  one manifest per snapshot
//	<store>/ledger.jsonl         default audit ledger
//
// Nothing in here is Windows-specific; a "driver store" is modelled as an
// ordinary directory tree so the whole thing is testable anywhere.
package main

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

// ---------------------------------------------------------------------------
// On-disk records
// ---------------------------------------------------------------------------

// FileEntry is one file inside a snapshot manifest. Path is always a
// slash-separated path relative to the snapshot root, never absolute.
type FileEntry struct {
	Path    string    `json:"path"`
	Hash    string    `json:"hash"`
	Size    int64     `json:"size"`
	Mode    uint32    `json:"mode"`
	ModTime time.Time `json:"modtime"`
}

// Manifest is the complete description of one snapshot.
type Manifest struct {
	ID            string      `json:"id"`
	Name          string      `json:"name,omitempty"`
	Created       time.Time   `json:"created"`
	Source        string      `json:"source"`
	Tool          string      `json:"tool"`
	Files         []FileEntry `json:"files"`
	FileCount     int         `json:"file_count"`
	DirCount      int         `json:"dir_count"`
	SkippedCount  int         `json:"skipped_count"`
	ApparentBytes int64       `json:"apparent_bytes"`
	UniqueObjects int         `json:"unique_objects"`
	UniqueBytes   int64       `json:"unique_bytes"`
	AddedObjects  int         `json:"added_objects"`
	AddedBytes    int64       `json:"added_bytes"`
}

// AuditFile is the per-file outcome of one mutating operation.
type AuditFile struct {
	Path        string `json:"path"`
	Action      string `json:"action"`
	Status      string `json:"status"`
	Hash        string `json:"hash,omitempty"`
	Bytes       int64  `json:"bytes,omitempty"`
	Quarantined string `json:"quarantined,omitempty"`
	Detail      string `json:"detail,omitempty"`
}

// AuditRecord is one JSON line of the append-only ledger. Only mutating runs
// (snapshot, and restore --apply) write one.
type AuditRecord struct {
	TS         time.Time      `json:"ts"`
	Tool       string         `json:"tool"`
	Op         string         `json:"op"`
	Snapshot   string         `json:"snapshot"`
	Store      string         `json:"store"`
	Source     string         `json:"source,omitempty"`
	Target     string         `json:"target,omitempty"`
	Quarantine string         `json:"quarantine,omitempty"`
	Result     string         `json:"result"`
	Error      string         `json:"error,omitempty"`
	Counts     map[string]int `json:"counts"`
	Bytes      int64          `json:"bytes"`
	Files      []AuditFile    `json:"files"`
}

// ---------------------------------------------------------------------------
// Store layout
// ---------------------------------------------------------------------------

// Store is a content-addressed object store plus a set of snapshot manifests.
type Store struct {
	Root string
}

func openStore(root string) (*Store, error) {
	abs, err := filepath.Abs(root)
	if err != nil {
		return nil, fmt.Errorf("cannot resolve store %q: %w", root, err)
	}
	return &Store{Root: abs}, nil
}

func (s *Store) objectsDir() string   { return filepath.Join(s.Root, "objects") }
func (s *Store) snapshotsDir() string { return filepath.Join(s.Root, "snapshots") }

// objectPath fans the hash out over one 2-character directory level so a store
// with a million blobs does not put a million entries in one directory.
func (s *Store) objectPath(hash string) string {
	return filepath.Join(s.objectsDir(), hash[:2], hash[2:])
}

func (s *Store) manifestPath(id string) string {
	return filepath.Join(s.snapshotsDir(), id+".json")
}

func (s *Store) init() error {
	for _, d := range []string{s.Root, s.objectsDir(), s.snapshotsDir()} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", d, err)
		}
	}
	return nil
}

var errNoStore = errors.New("no store")

func (s *Store) mustExist() error {
	info, err := os.Stat(s.Root)
	if err != nil {
		if os.IsNotExist(err) {
			return errNoStore
		}
		return fmt.Errorf("cannot read store %s: %w", s.Root, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("store %s is not a directory", s.Root)
	}
	return nil
}

// hasObject reports whether the blob is already stored, and its stored size.
func (s *Store) hasObject(hash string) (bool, int64) {
	info, err := os.Stat(s.objectPath(hash))
	if err != nil {
		return false, 0
	}
	return true, info.Size()
}

// putObject copies src into the store under its hash, exactly once. It returns
// true when this call is what actually added the blob (i.e. it was not already
// there), which is how snapshot accounts for deduplication.
func (s *Store) putObject(hash, src string) (added bool, err error) {
	dst := s.objectPath(hash)
	if ok, _ := s.hasObject(hash); ok {
		return false, nil
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return false, fmt.Errorf("cannot create %s: %w", filepath.Dir(dst), err)
	}
	tmp := dst + ".part"
	if err := copyFile(src, tmp, 0o444); err != nil {
		os.Remove(tmp)
		return false, err
	}
	// Re-hash what we just wrote before publishing it under its name.
	got, _, err := hashFile(tmp)
	if err != nil {
		os.Remove(tmp)
		return false, err
	}
	if got != hash {
		os.Remove(tmp)
		return false, fmt.Errorf("object %s changed while being stored (read back %s)", hash, got)
	}
	if err := os.Rename(tmp, dst); err != nil {
		os.Remove(tmp)
		return false, fmt.Errorf("cannot store object %s: %w", hash, err)
	}
	return true, nil
}

func (s *Store) writeManifest(m *Manifest) error {
	if err := os.MkdirAll(s.snapshotsDir(), 0o755); err != nil {
		return fmt.Errorf("cannot create %s: %w", s.snapshotsDir(), err)
	}
	blob, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return fmt.Errorf("cannot encode manifest: %w", err)
	}
	blob = append(blob, '\n')
	final := s.manifestPath(m.ID)
	tmp := final + ".part"
	if err := os.WriteFile(tmp, blob, 0o644); err != nil {
		return fmt.Errorf("cannot write manifest: %w", err)
	}
	if err := os.Rename(tmp, final); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("cannot write manifest: %w", err)
	}
	return nil
}

func (s *Store) loadManifest(id string) (*Manifest, error) {
	id = strings.TrimSuffix(id, ".json")
	blob, err := os.ReadFile(s.manifestPath(id))
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("no snapshot %q in store %s", id, s.Root)
		}
		return nil, fmt.Errorf("cannot read snapshot %q: %w", id, err)
	}
	var m Manifest
	if err := json.Unmarshal(blob, &m); err != nil {
		return nil, fmt.Errorf("snapshot %q is not valid JSON: %w", id, err)
	}
	sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path })
	return &m, nil
}

// listManifests returns every manifest in the store, oldest first.
func (s *Store) listManifests() ([]*Manifest, error) {
	ents, err := os.ReadDir(s.snapshotsDir())
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("cannot read %s: %w", s.snapshotsDir(), err)
	}
	var out []*Manifest
	for _, e := range ents {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		m, err := s.loadManifest(strings.TrimSuffix(e.Name(), ".json"))
		if err != nil {
			return nil, err
		}
		out = append(out, m)
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Created.Equal(out[j].Created) {
			return out[i].ID < out[j].ID
		}
		return out[i].Created.Before(out[j].Created)
	})
	return out, nil
}

// newSnapshotID mints a timestamp-based id, adding a numeric suffix if a
// snapshot with that id already exists (two snapshots in the same second).
func (s *Store) newSnapshotID(now time.Time) string {
	base := "snap-" + now.UTC().Format("20060102T150405Z")
	id := base
	for n := 2; ; n++ {
		if _, err := os.Stat(s.manifestPath(id)); os.IsNotExist(err) {
			return id
		}
		id = fmt.Sprintf("%s-%d", base, n)
	}
}

// ---------------------------------------------------------------------------
// Hashing and copying
// ---------------------------------------------------------------------------

func hashFile(path string) (string, int64, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, fmt.Errorf("cannot read %s: %w", path, err)
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", 0, fmt.Errorf("cannot read %s: %w", path, err)
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

func copyFile(src, dst string, mode os.FileMode) error {
	in, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("cannot read %s: %w", src, err)
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
	if err != nil {
		return fmt.Errorf("cannot write %s: %w", dst, err)
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return fmt.Errorf("cannot write %s: %w", dst, err)
	}
	if err := out.Sync(); err != nil {
		out.Close()
		return fmt.Errorf("cannot flush %s: %w", dst, err)
	}
	return out.Close()
}

// ---------------------------------------------------------------------------
// Scanning a tree
// ---------------------------------------------------------------------------

// scanned is the hashed state of one directory tree.
type scanned struct {
	Files   map[string]FileEntry // keyed by slash-relative path
	Dirs    int
	Skipped []string // non-regular entries (symlinks, devices, sockets)
}

// scanTree walks root, hashing every regular file. exclude is an absolute path
// that is pruned from the walk (used to keep the store out of its own
// snapshots when the store lives inside the source tree). Symbolic links are
// never followed.
func scanTree(root string, exclude string) (*scanned, error) {
	sc := &scanned{Files: map[string]FileEntry{}}
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return fmt.Errorf("cannot read %s: %w", p, err)
		}
		if exclude != "" && p == exclude {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		rel, rerr := filepath.Rel(root, p)
		if rerr != nil {
			return fmt.Errorf("cannot resolve %s: %w", p, rerr)
		}
		rel = filepath.ToSlash(rel)
		if d.IsDir() {
			if rel != "." {
				sc.Dirs++
			}
			return nil
		}
		if !d.Type().IsRegular() {
			sc.Skipped = append(sc.Skipped, rel)
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			return fmt.Errorf("cannot stat %s: %w", p, ierr)
		}
		hash, n, herr := hashFile(p)
		if herr != nil {
			return herr
		}
		if n != info.Size() {
			return fmt.Errorf("%s changed size while being read (%d -> %d)", p, info.Size(), n)
		}
		sc.Files[rel] = FileEntry{
			Path:    rel,
			Hash:    hash,
			Size:    n,
			Mode:    uint32(info.Mode().Perm()),
			ModTime: info.ModTime().UTC(),
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(sc.Skipped)
	return sc, nil
}

// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------

func takeSnapshot(s *Store, source, name string, now time.Time) (*Manifest, []AuditFile, error) {
	absSrc, err := filepath.Abs(source)
	if err != nil {
		return nil, nil, fmt.Errorf("cannot resolve %q: %w", source, err)
	}
	info, err := os.Stat(absSrc)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil, fmt.Errorf("source %q does not exist", source)
		}
		return nil, nil, fmt.Errorf("cannot stat %q: %w", source, err)
	}
	if !info.IsDir() {
		return nil, nil, fmt.Errorf("source %q is not a directory", source)
	}
	if err := s.init(); err != nil {
		return nil, nil, err
	}

	sc, err := scanTree(absSrc, s.Root)
	if err != nil {
		return nil, nil, err
	}

	m := &Manifest{
		ID:           s.newSnapshotID(now),
		Name:         name,
		Created:      now.UTC(),
		Source:       absSrc,
		Tool:         appName + " " + version,
		DirCount:     sc.Dirs,
		SkippedCount: len(sc.Skipped),
	}

	paths := make([]string, 0, len(sc.Files))
	for p := range sc.Files {
		paths = append(paths, p)
	}
	sort.Strings(paths)

	var audit []AuditFile
	uniq := map[string]int64{}
	for _, p := range paths {
		fe := sc.Files[p]
		m.Files = append(m.Files, fe)
		m.ApparentBytes += fe.Size
		action := "dedup"
		if _, seen := uniq[fe.Hash]; !seen {
			uniq[fe.Hash] = fe.Size
			added, err := s.putObject(fe.Hash, filepath.Join(absSrc, filepath.FromSlash(p)))
			if err != nil {
				return nil, nil, err
			}
			if added {
				m.AddedObjects++
				m.AddedBytes += fe.Size
				action = "store"
			}
		}
		audit = append(audit, AuditFile{
			Path: p, Action: action, Status: "ok", Hash: fe.Hash, Bytes: fe.Size,
		})
	}
	m.FileCount = len(m.Files)
	m.UniqueObjects = len(uniq)
	for _, n := range uniq {
		m.UniqueBytes += n
	}
	if err := s.writeManifest(m); err != nil {
		return nil, nil, err
	}
	return m, audit, nil
}

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

// DiffEntry is one path that differs (or does not) between two snapshots.
type DiffEntry struct {
	Path      string `json:"path"`
	Change    string `json:"change"` // added | removed | changed | unchanged
	FromHash  string `json:"from_hash,omitempty"`
	ToHash    string `json:"to_hash,omitempty"`
	FromSize  int64  `json:"from_size"`
	ToSize    int64  `json:"to_size"`
	SizeDelta int64  `json:"size_delta"`
}

// DiffReport is the full comparison of snapshot A against snapshot B.
type DiffReport struct {
	From         string      `json:"from"`
	To           string      `json:"to"`
	Added        int         `json:"added"`
	Removed      int         `json:"removed"`
	Changed      int         `json:"changed"`
	Unchanged    int         `json:"unchanged"`
	BytesAdded   int64       `json:"bytes_added"`
	BytesRemoved int64       `json:"bytes_removed"`
	BytesDelta   int64       `json:"bytes_delta"`
	FromBytes    int64       `json:"from_bytes"`
	ToBytes      int64       `json:"to_bytes"`
	Entries      []DiffEntry `json:"entries"`
}

func diffSnapshots(a, b *Manifest) *DiffReport {
	byPath := func(m *Manifest) map[string]FileEntry {
		out := make(map[string]FileEntry, len(m.Files))
		for _, f := range m.Files {
			out[f.Path] = f
		}
		return out
	}
	am, bm := byPath(a), byPath(b)

	seen := map[string]bool{}
	var paths []string
	for p := range am {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	for p := range bm {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	sort.Strings(paths)

	r := &DiffReport{From: a.ID, To: b.ID, FromBytes: a.ApparentBytes, ToBytes: b.ApparentBytes}
	for _, p := range paths {
		af, inA := am[p]
		bf, inB := bm[p]
		e := DiffEntry{Path: p}
		switch {
		case !inA && inB:
			e.Change = "added"
			e.ToHash, e.ToSize = bf.Hash, bf.Size
			e.SizeDelta = bf.Size
			r.Added++
			r.BytesAdded += bf.Size
		case inA && !inB:
			e.Change = "removed"
			e.FromHash, e.FromSize = af.Hash, af.Size
			e.SizeDelta = -af.Size
			r.Removed++
			r.BytesRemoved += af.Size
		case af.Hash != bf.Hash:
			e.Change = "changed"
			e.FromHash, e.ToHash = af.Hash, bf.Hash
			e.FromSize, e.ToSize = af.Size, bf.Size
			e.SizeDelta = bf.Size - af.Size
			r.Changed++
		default:
			e.Change = "unchanged"
			e.FromHash, e.ToHash = af.Hash, bf.Hash
			e.FromSize, e.ToSize = af.Size, bf.Size
			r.Unchanged++
		}
		r.Entries = append(r.Entries, e)
	}
	r.BytesDelta = r.ToBytes - r.FromBytes
	if r.Entries == nil {
		r.Entries = []DiffEntry{}
	}
	return r
}

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

// Op codes used by the restore plan.
const (
	opCreate    = "create"
	opOverwrite = "overwrite"
	opRemove    = "remove"
	opKeep      = "keep"
	opConflict  = "conflict"
)

// PlanOp is one operation restore would perform on the target tree.
type PlanOp struct {
	Op         string `json:"op"`
	Path       string `json:"path"`
	WantHash   string `json:"want_hash,omitempty"`
	HaveHash   string `json:"have_hash,omitempty"`
	WantSize   int64  `json:"want_size"`
	HaveSize   int64  `json:"have_size"`
	Detail     string `json:"detail,omitempty"`
	Applied    bool   `json:"applied"`
	Quarantine string `json:"quarantined_to,omitempty"`
}

// RestorePlan is the minimal set of operations that turns the target tree back
// into the snapshot state.
type RestorePlan struct {
	Snapshot     string   `json:"snapshot"`
	Target       string   `json:"target"`
	Store        string   `json:"store"`
	Create       int      `json:"create"`
	Overwrite    int      `json:"overwrite"`
	Remove       int      `json:"remove"`
	Keep         int      `json:"keep"`
	Conflict     int      `json:"conflict"`
	BytesWritten int64    `json:"bytes_to_write"`
	BytesMoved   int64    `json:"bytes_to_quarantine"`
	Ops          []PlanOp `json:"ops"`
}

// Mutations returns the number of operations that would change the target.
func (p *RestorePlan) Mutations() int { return p.Create + p.Overwrite + p.Remove }

func planRestore(s *Store, m *Manifest, target string) (*RestorePlan, error) {
	absTarget, err := filepath.Abs(target)
	if err != nil {
		return nil, fmt.Errorf("cannot resolve %q: %w", target, err)
	}
	info, err := os.Stat(absTarget)
	switch {
	case err != nil && os.IsNotExist(err):
		// Restoring into a fresh directory is legitimate: everything is a create.
	case err != nil:
		return nil, fmt.Errorf("cannot stat %q: %w", target, err)
	case !info.IsDir():
		return nil, fmt.Errorf("target %q is not a directory", target)
	}

	have := &scanned{Files: map[string]FileEntry{}}
	if err == nil {
		have, err = scanTree(absTarget, s.Root)
		if err != nil {
			return nil, err
		}
	}

	want := make(map[string]FileEntry, len(m.Files))
	for _, f := range m.Files {
		want[f.Path] = f
	}

	seen := map[string]bool{}
	var paths []string
	for p := range want {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	for p := range have.Files {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	// Non-regular entries in the target still occupy a path and must be
	// accounted for; they can never satisfy a snapshot entry.
	irregular := make(map[string]bool, len(have.Skipped))
	for _, p := range have.Skipped {
		irregular[p] = true
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	sort.Strings(paths)

	plan := &RestorePlan{Snapshot: m.ID, Target: absTarget, Store: s.Root}
	for _, p := range paths {
		wf, wantIt := want[p]
		hf, haveIt := have.Files[p]
		nonRegular := irregular[p]
		op := PlanOp{Path: p}
		switch {
		case wantIt && !haveIt && !nonRegular:
			// A directory sitting where the snapshot wants a file is a
			// conflict: DriverRollback never moves or deletes whole trees.
			if st, serr := os.Lstat(filepath.Join(absTarget, filepath.FromSlash(p))); serr == nil && st.IsDir() {
				op.Op = opConflict
				op.WantHash, op.WantSize = wf.Hash, wf.Size
				op.Detail = "target holds a directory where the snapshot holds a file"
				plan.Conflict++
				break
			}
			op.Op = opCreate
			op.WantHash, op.WantSize = wf.Hash, wf.Size
			plan.Create++
			plan.BytesWritten += wf.Size
		case wantIt && nonRegular:
			op.Op = opOverwrite
			op.WantHash, op.WantSize = wf.Hash, wf.Size
			op.Detail = "target entry is not a regular file"
			plan.Overwrite++
			plan.BytesWritten += wf.Size
		case wantIt && haveIt && hf.Hash != wf.Hash:
			op.Op = opOverwrite
			op.WantHash, op.WantSize = wf.Hash, wf.Size
			op.HaveHash, op.HaveSize = hf.Hash, hf.Size
			plan.Overwrite++
			plan.BytesWritten += wf.Size
			plan.BytesMoved += hf.Size
		case wantIt && haveIt:
			op.Op = opKeep
			op.WantHash, op.HaveHash = wf.Hash, hf.Hash
			op.WantSize, op.HaveSize = wf.Size, hf.Size
			plan.Keep++
		case nonRegular:
			op.Op = opRemove
			op.Detail = "target entry is not a regular file"
			plan.Remove++
		default:
			op.Op = opRemove
			op.HaveHash, op.HaveSize = hf.Hash, hf.Size
			plan.Remove++
			plan.BytesMoved += hf.Size
		}
		plan.Ops = append(plan.Ops, op)
	}
	if plan.Ops == nil {
		plan.Ops = []PlanOp{}
	}
	return plan, nil
}

// ---------------------------------------------------------------------------
// restore application
// ---------------------------------------------------------------------------

// defaultQuarantine is a timestamped directory beside the target, never inside
// it, so quarantined files are not picked up by the next snapshot of the tree.
func defaultQuarantine(absTarget string, now time.Time) string {
	base := filepath.Base(absTarget) + ".quarantine"
	return filepath.Join(filepath.Dir(absTarget), base, now.UTC().Format("20060102T150405Z"))
}

// quarantineFile MOVES path into the quarantine root, preserving its relative
// layout. Nothing is ever unlinked: if a rename across devices fails, the file
// is copied and only then removed from the target, and the copy is verified
// first. Returns the quarantine path.
func quarantineFile(absTarget, quarantineRoot, rel string) (string, error) {
	src := filepath.Join(absTarget, filepath.FromSlash(rel))
	dst := filepath.Join(quarantineRoot, filepath.FromSlash(rel))
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return "", fmt.Errorf("cannot create quarantine dir %s: %w", filepath.Dir(dst), err)
	}
	// Never clobber something already in quarantine.
	final := dst
	for n := 2; ; n++ {
		if _, err := os.Lstat(final); os.IsNotExist(err) {
			break
		}
		final = fmt.Sprintf("%s.%d", dst, n)
	}
	if err := os.Rename(src, final); err == nil {
		return final, nil
	}
	// Cross-device fallback: copy, verify, then remove the original.
	st, err := os.Lstat(src)
	if err != nil {
		return "", fmt.Errorf("cannot quarantine %s: %w", rel, err)
	}
	if !st.Mode().IsRegular() {
		return "", fmt.Errorf("cannot quarantine %s: not a regular file and cannot be renamed", rel)
	}
	want, _, err := hashFile(src)
	if err != nil {
		return "", err
	}
	if err := copyFile(src, final, 0o644); err != nil {
		return "", fmt.Errorf("cannot quarantine %s: %w", rel, err)
	}
	got, _, err := hashFile(final)
	if err != nil {
		return "", err
	}
	if got != want {
		return "", fmt.Errorf("quarantine copy of %s does not match (%s != %s)", rel, got, want)
	}
	if err := os.Remove(src); err != nil {
		return "", fmt.Errorf("cannot move %s out of the target: %w", rel, err)
	}
	return final, nil
}

// applyRestore executes the plan. Every displaced file is quarantined before
// anything is written, every written file is re-hashed against the manifest,
// and a mismatch is a hard error.
func applyRestore(s *Store, m *Manifest, plan *RestorePlan, quarantineRoot string) ([]AuditFile, error) {
	if plan.Conflict > 0 {
		return nil, fmt.Errorf("%d path(s) conflict with a directory in the target; resolve them by hand", plan.Conflict)
	}
	// Fail before touching anything if the store cannot satisfy the plan.
	for _, op := range plan.Ops {
		if op.Op != opCreate && op.Op != opOverwrite {
			continue
		}
		if ok, _ := s.hasObject(op.WantHash); !ok {
			return nil, fmt.Errorf("store is missing object %s needed by %s - run %s verify %s", op.WantHash, op.Path, appName, m.ID)
		}
	}

	index := make(map[string]FileEntry, len(m.Files))
	for _, f := range m.Files {
		index[f.Path] = f
	}

	var audit []AuditFile
	for i := range plan.Ops {
		op := &plan.Ops[i]
		switch op.Op {
		case opKeep:
			audit = append(audit, AuditFile{Path: op.Path, Action: opKeep, Status: "ok", Hash: op.WantHash, Bytes: op.WantSize})
			continue
		case opRemove:
			q, err := quarantineFile(plan.Target, quarantineRoot, op.Path)
			if err != nil {
				return audit, err
			}
			op.Applied = true
			op.Quarantine = q
			audit = append(audit, AuditFile{
				Path: op.Path, Action: opRemove, Status: "quarantined",
				Hash: op.HaveHash, Bytes: op.HaveSize, Quarantined: q,
			})
			continue
		case opOverwrite:
			q, err := quarantineFile(plan.Target, quarantineRoot, op.Path)
			if err != nil {
				return audit, err
			}
			op.Quarantine = q
		}
		// create and overwrite both end here: write from the object store.
		fe, ok := index[op.Path]
		if !ok {
			return audit, fmt.Errorf("internal: %s is not in snapshot %s", op.Path, m.ID)
		}
		if err := materialise(s, plan.Target, fe); err != nil {
			return audit, err
		}
		op.Applied = true
		af := AuditFile{Path: op.Path, Action: op.Op, Status: "restored", Hash: fe.Hash, Bytes: fe.Size}
		if op.Quarantine != "" {
			af.Quarantined = op.Quarantine
			af.Status = "restored+quarantined"
		}
		audit = append(audit, af)
	}
	return audit, nil
}

// materialise writes one manifest entry into the target via a .part temp file
// and an atomic rename, then re-hashes the result and refuses to accept a
// mismatch.
func materialise(s *Store, absTarget string, fe FileEntry) error {
	dst := filepath.Join(absTarget, filepath.FromSlash(fe.Path))
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return fmt.Errorf("cannot create %s: %w", filepath.Dir(dst), err)
	}
	mode := os.FileMode(fe.Mode).Perm()
	if mode == 0 {
		mode = 0o644
	}
	tmp := dst + ".part"
	if err := copyFile(s.objectPath(fe.Hash), tmp, mode); err != nil {
		os.Remove(tmp)
		return err
	}
	got, n, err := hashFile(tmp)
	if err != nil {
		os.Remove(tmp)
		return err
	}
	if got != fe.Hash || n != fe.Size {
		os.Remove(tmp)
		return fmt.Errorf("restored %s does not match the manifest: got sha256:%s (%d bytes), want sha256:%s (%d bytes)",
			fe.Path, got, n, fe.Hash, fe.Size)
	}
	if err := os.Rename(tmp, dst); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("cannot place %s: %w", fe.Path, err)
	}
	if err := os.Chmod(dst, mode); err != nil {
		return fmt.Errorf("cannot set mode on %s: %w", fe.Path, err)
	}
	if !fe.ModTime.IsZero() {
		if err := os.Chtimes(dst, fe.ModTime, fe.ModTime); err != nil {
			return fmt.Errorf("cannot set modtime on %s: %w", fe.Path, err)
		}
	}
	// Final check on the published file, not just the temp file.
	got, n, err = hashFile(dst)
	if err != nil {
		return err
	}
	if got != fe.Hash || n != fe.Size {
		return fmt.Errorf("restored %s does not match the manifest after rename: got sha256:%s (%d bytes), want sha256:%s (%d bytes)",
			fe.Path, got, n, fe.Hash, fe.Size)
	}
	return nil
}

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

// VerifyObject is the outcome of re-hashing one object a snapshot references.
type VerifyObject struct {
	Hash   string   `json:"hash"`
	Size   int64    `json:"size"`
	Status string   `json:"status"` // ok | missing | corrupt | unreadable
	Got    string   `json:"got,omitempty"`
	GotSiz int64    `json:"got_size,omitempty"`
	Paths  []string `json:"paths"`
	Detail string   `json:"detail,omitempty"`
}

// VerifyReport is the full result of verifying one snapshot.
type VerifyReport struct {
	Snapshot   string         `json:"snapshot"`
	Store      string         `json:"store"`
	Objects    int            `json:"objects"`
	OK         int            `json:"ok"`
	Missing    int            `json:"missing"`
	Corrupt    int            `json:"corrupt"`
	Unreadable int            `json:"unreadable"`
	Bytes      int64          `json:"bytes_verified"`
	Healthy    bool           `json:"healthy"`
	Results    []VerifyObject `json:"results"`
}

func verifySnapshot(s *Store, m *Manifest) *VerifyReport {
	type agg struct {
		size  int64
		paths []string
	}
	byHash := map[string]*agg{}
	var order []string
	for _, f := range m.Files {
		a, ok := byHash[f.Hash]
		if !ok {
			a = &agg{size: f.Size}
			byHash[f.Hash] = a
			order = append(order, f.Hash)
		}
		a.paths = append(a.paths, f.Path)
	}
	sort.Strings(order)

	r := &VerifyReport{Snapshot: m.ID, Store: s.Root, Objects: len(order)}
	for _, h := range order {
		a := byHash[h]
		sort.Strings(a.paths)
		vo := VerifyObject{Hash: h, Size: a.size, Paths: a.paths}
		p := s.objectPath(h)
		if _, err := os.Stat(p); err != nil {
			if os.IsNotExist(err) {
				vo.Status = "missing"
				vo.Detail = "object is not in the store"
				r.Missing++
			} else {
				vo.Status = "unreadable"
				vo.Detail = err.Error()
				r.Unreadable++
			}
			r.Results = append(r.Results, vo)
			continue
		}
		got, n, err := hashFile(p)
		if err != nil {
			vo.Status = "unreadable"
			vo.Detail = err.Error()
			r.Unreadable++
			r.Results = append(r.Results, vo)
			continue
		}
		vo.Got, vo.GotSiz = got, n
		if got != h {
			vo.Status = "corrupt"
			vo.Detail = "stored bytes do not hash to the object name"
			r.Corrupt++
		} else if n != a.size {
			vo.Status = "corrupt"
			vo.Detail = "stored size does not match the manifest"
			r.Corrupt++
		} else {
			vo.Status = "ok"
			r.OK++
			r.Bytes += n
		}
		r.Results = append(r.Results, vo)
	}
	if r.Results == nil {
		r.Results = []VerifyObject{}
	}
	r.Healthy = r.Missing == 0 && r.Corrupt == 0 && r.Unreadable == 0
	return r
}

// ---------------------------------------------------------------------------
// ledger
// ---------------------------------------------------------------------------

func appendLedger(path string, rec AuditRecord) error {
	line, err := json.Marshal(rec)
	if err != nil {
		return fmt.Errorf("cannot encode audit record: %w", err)
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot open ledger %s: %w", path, err)
	}
	if _, err := f.Write(append(line, '\n')); err != nil {
		f.Close()
		return fmt.Errorf("cannot append to ledger %s: %w", path, err)
	}
	return f.Close()
}
