package filesystem

import (
	"fmt"
	"os"
	"path"
	"path/filepath"
	"strconv"

	"github.com/OffchainLabs/prysm/v7/config/params"
	"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
	"github.com/pkg/errors"
	"github.com/spf13/afero"
)

const epochsPerDirectory = 4096

type periodicEpochLayout struct {
	fs     afero.Fs
	cache  *blobStorageSummaryCache
	pruner *blobPruner
}

var _ fsLayout = &periodicEpochLayout{}

func newPeriodicEpochLayout(fs afero.Fs, cache *blobStorageSummaryCache, pruner *blobPruner) fsLayout {
	l := &periodicEpochLayout{fs: fs, cache: cache, pruner: pruner}
	return l
}

func (l *periodicEpochLayout) name() string {
	return LayoutNameByEpoch
}

func (l *periodicEpochLayout) blockParentDirs(ident blobIdent) []string {
	return []string{
		PeriodicEpochBaseDir,
		l.periodDir(ident.epoch),
		l.epochDir(ident.epoch),
	}
}

func (l *periodicEpochLayout) notify(ident blobIdent) error {
	if err := l.cache.ensure(ident); err != nil {
		return err
	}
	l.pruner.notify(ident.epoch, l)
	return nil
}

// If before == 0, it won't be used as a filter and all idents will be returned.
func (l *periodicEpochLayout) iterateIdents(before primitives.Epoch) (*identIterator, error) {
	_, err := l.fs.Stat(PeriodicEpochBaseDir)
	if err != nil {
		if os.IsNotExist(err) {
			return &identIterator{eof: true}, nil // The directory is non-existent, which is fine; stop iteration.
		}
		return nil, errors.Wrapf(err, "error reading path %s", PeriodicEpochBaseDir)
	}
	// iterate root, which should have directories named by "period"
	entries, err := listDir(l.fs, PeriodicEpochBaseDir)
	if err != nil {
		return nil, errors.Wrapf(err, "failed to list %s", PeriodicEpochBaseDir)
	}

	return &identIterator{
		fs:   l.fs,
		path: PeriodicEpochBaseDir,
		// Please see comments on the `layers` field in `identIterator`` if the role of the layers is unclear.
		layers: []layoutLayer{
			{populateIdent: populateNoop, filter: isBeforePeriod(before)},
			{populateIdent: populateEpoch, filter: isBeforeEpoch(before)},
			{populateIdent: populateRoot, filter: IsBlockRootDir}, // extract root from path
			{populateIdent: populateIndex, filter: isSszFile},     // extract index from filename
		},
		entries: entries,
	}, nil
}

func (l *periodicEpochLayout) ident(root [32]byte, idx uint64) (blobIdent, error) {
	return l.cache.identForIdx(root, idx)
}

func (l *periodicEpochLayout) dirIdent(root [32]byte) (blobIdent, error) {
	return l.cache.identForRoot(root)
}

func (l *periodicEpochLayout) summary(root [32]byte) BlobStorageSummary {
	return l.cache.Summary(root)
}

func (l *periodicEpochLayout) dir(n blobIdent) string {
	return filepath.Join(l.epochDir(n.epoch), rootToString(n.root))
}

func (l *periodicEpochLayout) epochDir(epoch primitives.Epoch) string {
	return filepath.Join(l.periodDir(epoch), fmt.Sprintf("%d", epoch))
}

func (l *periodicEpochLayout) periodDir(epoch primitives.Epoch) string {
	return filepath.Join(PeriodicEpochBaseDir, fmt.Sprintf("%d", periodForEpoch(epoch)))
}

func (l *periodicEpochLayout) sszPath(n blobIdent) string {
	return filepath.Join(l.dir(n), n.sszFname())
}

func (l *periodicEpochLayout) partPath(n blobIdent, entropy string) string {
	return path.Join(l.dir(n), n.partFname(entropy))
}

func (l *periodicEpochLayout) pruneBefore(before primitives.Epoch) (*pruneSummary, error) {
	sums, err := pruneBefore(before, l)
	if err != nil {
		return nil, err
	}
	// Roll up summaries and clean up per-epoch directories.
	rollup := &pruneSummary{}

	// Track which period directories might be empty after epoch removal
	periodsToCheck := make(map[string]struct{})

	for epoch, sum := range sums {
		rollup.blobsPruned += sum.blobsPruned
		rollup.failedRemovals = append(rollup.failedRemovals, sum.failedRemovals...)
		rmdir := l.epochDir(epoch)
		periodDir := l.periodDir(epoch)

		if len(sum.failedRemovals) == 0 {
			if err := l.fs.Remove(rmdir); err != nil {
				log.WithField("dir", rmdir).WithError(err).Error("Failed to remove epoch directory while pruning")
			} else {
				periodsToCheck[periodDir] = struct{}{}
			}
		} else {
			log.WithField("dir", rmdir).WithField("numFailed", len(sum.failedRemovals)).WithError(err).Error("Unable to remove epoch directory due to pruning failures")
		}
	}

	//Clean up empty period directories
	for periodDir := range periodsToCheck {
		entries, err := afero.ReadDir(l.fs, periodDir)
		if err != nil {
			log.WithField("dir", periodDir).WithError(err).Debug("Failed to read period directory contents")
			continue
		}

		// Only attempt to remove if directory is empty
		if len(entries) == 0 {
			if err := l.fs.Remove(periodDir); err != nil {
				log.WithField("dir", periodDir).WithError(err).Error("Failed to remove empty period directory")
			}
		}
	}

	return rollup, nil
}

func (l *periodicEpochLayout) remove(ident blobIdent) (int, error) {
	removed := l.cache.evict(ident.root)
	// Skip the syscall if there are no blobs to remove.
	if removed == 0 {
		return 0, nil
	}
	if err := l.fs.RemoveAll(l.dir(ident)); err != nil {
		return removed, err
	}
	return removed, nil
}

func periodForEpoch(epoch primitives.Epoch) primitives.Epoch {
	return epoch / params.BeaconConfig().MinEpochsForBlobsSidecarsRequest
}

// Funcs below this line are iteration support methods that are specific to the epoch layout.

func isBeforePeriod(before primitives.Epoch) func(string) bool {
	if before == 0 {
		return filterNoop
	}
	beforePeriod := periodForEpoch(before)
	if before%epochsPerDirectory != 0 {
		// Add one because we need to include the period the epoch is in, unless it is the first epoch in the period,
		// in which case we can just look at any previous period.
		beforePeriod += 1
	}
	return func(p string) bool {
		period, err := periodFromPath(p)
		if err != nil {
			return false
		}
		return primitives.Epoch(period) < beforePeriod
	}
}

func isBeforeEpoch(before primitives.Epoch) func(string) bool {
	if before == 0 {
		return filterNoop
	}
	return func(p string) bool {
		epoch, err := epochFromPath(p)
		if err != nil {
			return false
		}
		return epoch < before
	}
}

func epochFromPath(p string) (primitives.Epoch, error) {
	subdir := filepath.Base(p)
	epoch, err := strconv.ParseUint(subdir, 10, 64)
	if err != nil {
		return 0, errors.Wrapf(errInvalidDirectoryLayout,
			"failed to decode epoch as uint, err=%s, dir=%s", err.Error(), p)
	}
	return primitives.Epoch(epoch), nil
}

func periodFromPath(p string) (uint64, error) {
	subdir := filepath.Base(p)
	period, err := strconv.ParseUint(subdir, 10, 64)
	if err != nil {
		return 0, errors.Wrapf(errInvalidDirectoryLayout,
			"failed to decode period from path as uint, err=%s, dir=%s", err.Error(), p)
	}
	return period, nil
}

func populateEpoch(namer blobIdent, dir string) (blobIdent, error) {
	epoch, err := epochFromPath(dir)
	if err != nil {
		return namer, err
	}
	namer.epoch = epoch
	return namer, nil
}
