package daemon

import (
	"context"
	crand "crypto/rand"
	"encoding/hex"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"

	"banger/internal/api"
)

// PruneOCICache removes every blob under the OCI layer cache. The
// cache is purely a re-pull-avoidance (every flattened image is
// independent of the blobs that sourced it), so the worst-case
// outcome of pruning is "next pull of the same ref re-downloads its
// layers" — a reasonable disk-hygiene knob.
//
// DryRun=true walks the cache and returns the size that WOULD be
// freed without touching anything; tests and CLI consumers print
// that summary so the operator can decide.
//
// Concurrent in-flight pulls may break if they're mid-fetch when
// the rename happens. That tradeoff is documented in the CLI help
// and docs/oci-import.md; the prune is an operator action, not a
// background sweep.
func (s *ImageService) PruneOCICache(_ context.Context, params api.ImageCachePruneParams) (api.ImageCachePruneResult, error) {
	cacheDir := s.layout.OCICacheDir
	bytes, blobs, err := walkCacheUsage(cacheDir)
	if err != nil {
		return api.ImageCachePruneResult{}, fmt.Errorf("inspect oci cache: %w", err)
	}
	res := api.ImageCachePruneResult{
		BytesFreed: bytes,
		BlobsFreed: blobs,
		DryRun:     params.DryRun,
		CacheDir:   cacheDir,
	}
	if params.DryRun || blobs == 0 {
		return res, nil
	}
	// Atomic rename aside so a follow-up pull doesn't see a half-
	// removed tree, then rm -rf the renamed dir, then recreate the
	// empty cache so future pulls find their write target.
	aside, err := renameAside(cacheDir)
	if err != nil {
		if os.IsNotExist(err) {
			return res, nil
		}
		return api.ImageCachePruneResult{}, fmt.Errorf("rename oci cache aside: %w", err)
	}
	if err := os.MkdirAll(cacheDir, 0o755); err != nil {
		// Best-effort restore: try to rename back so the caller
		// isn't left with a vanished cache dir. If both moves
		// failed, surface both — the operator needs to know.
		if restoreErr := os.Rename(aside, cacheDir); restoreErr != nil {
			return api.ImageCachePruneResult{}, fmt.Errorf("recreate oci cache: %w (also failed to restore from %s: %v)", err, aside, restoreErr)
		}
		return api.ImageCachePruneResult{}, fmt.Errorf("recreate oci cache: %w", err)
	}
	if err := os.RemoveAll(aside); err != nil {
		return api.ImageCachePruneResult{}, fmt.Errorf("remove old oci cache (%s): %w", aside, err)
	}
	return res, nil
}

func walkCacheUsage(cacheDir string) (int64, int, error) {
	var bytes int64
	var blobs int
	err := filepath.WalkDir(cacheDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			// Cache dir doesn't exist yet (fresh install, no OCI
			// pulls so far) — that's not a prune error, it's a
			// 0-byte / 0-blob result.
			if os.IsNotExist(err) && path == cacheDir {
				return filepath.SkipAll
			}
			return err
		}
		if d.IsDir() {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		bytes += info.Size()
		blobs++
		return nil
	})
	if err != nil {
		return 0, 0, err
	}
	return bytes, blobs, nil
}

// renameAside moves cacheDir to a sibling temp path so the prune can
// rm-rf it without racing against fresh writes. Returns the aside
// path on success.
func renameAside(cacheDir string) (string, error) {
	var suffix [8]byte
	if _, err := crand.Read(suffix[:]); err != nil {
		return "", err
	}
	aside := cacheDir + ".pruning-" + hex.EncodeToString(suffix[:])
	if err := os.Rename(cacheDir, aside); err != nil {
		return "", err
	}
	return aside, nil
}
