package memorysearch

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"time"
)

const (
	defaultSoftPressureBytes        = 512 << 20
	defaultHardPressureBytes        = 1 << 30
	defaultSoftFreePercent   uint64 = 10
	defaultHardFreeBytes            = 2 << 30
)

// ErrBuildDiskPressure means a new build was rejected after pressure GC
// exhausted every non-current candidate and the per-agent derivative or its
// containing volume still exceeded a hard threshold. Existing current readers
// remain serviceable.
var ErrBuildDiskPressure = errors.New("memorysearch: build rejected by hard disk pressure")

// DiskUsage is one point-in-time measurement of a per-agent Memory Search
// derivative and the volume containing it.
type DiskUsage struct {
	Bytes      uint64
	FreeBytes  uint64
	TotalBytes uint64
}

// DiskUsageProvider is the single platform seam for pressure decisions.
// Implementations must count the complete derivative root, including
// generations, partial files, sidecars, and .trash.
type DiskUsageProvider interface {
	Measure(ctx context.Context, derivativeRoot string) (DiskUsage, error)
}

// PressurePolicy controls per-agent derivative and containing-volume limits.
// Zero fields use the V1 defaults.
type PressurePolicy struct {
	SoftBytes       uint64
	HardBytes       uint64
	SoftFreePercent uint64
	HardFreeBytes   uint64
}

func normalizePressurePolicy(policy PressurePolicy) (PressurePolicy, error) {
	if policy.SoftBytes == 0 {
		policy.SoftBytes = defaultSoftPressureBytes
	}
	if policy.HardBytes == 0 {
		policy.HardBytes = defaultHardPressureBytes
	}
	if policy.SoftFreePercent == 0 {
		policy.SoftFreePercent = defaultSoftFreePercent
	}
	if policy.HardFreeBytes == 0 {
		policy.HardFreeBytes = defaultHardFreeBytes
	}
	if policy.SoftBytes > policy.HardBytes {
		return PressurePolicy{}, fmt.Errorf(
			"memorysearch: soft pressure bytes %d exceed hard pressure bytes %d",
			policy.SoftBytes, policy.HardBytes)
	}
	if policy.SoftFreePercent > 100 {
		return PressurePolicy{}, fmt.Errorf(
			"memorysearch: soft free percent %d exceeds 100",
			policy.SoftFreePercent)
	}
	return policy, nil
}

func (p PressurePolicy) soft(usage DiskUsage) bool {
	if usage.Bytes >= p.SoftBytes {
		return true
	}
	return usage.TotalBytes > 0 &&
		float64(usage.FreeBytes)/float64(usage.TotalBytes)*100 <
			float64(p.SoftFreePercent)
}

// RelieveDiskPressure performs one bounded pressure pass. It returns true only
// when hard pressure remains after ordinary retention, trash sweeping, and
// emergency reclaim exhausted every safe non-current candidate.
func (r *Reconciler) RelieveDiskPressure(ctx context.Context) (bool, error) {
	r.pressureMu.Lock()
	defer r.pressureMu.Unlock()
	return r.relieveDiskPressureLocked(ctx)
}

func (r *Reconciler) relieveDiskPressureLocked(ctx context.Context) (bool, error) {
	usage, err := r.opts.DiskUsageProvider.Measure(ctx, r.derivativeRoot)
	if err != nil {
		return false, err
	}
	if !r.opts.PressurePolicy.soft(usage) &&
		!r.opts.PressurePolicy.hard(usage) {
		return false, nil
	}
	if err := r.RunRetentionGC(ctx); err != nil {
		return false, err
	}
	if err := sweepTrash(r.derivativeRoot); err != nil {
		return false, err
	}
	usage, err = r.opts.DiskUsageProvider.Measure(ctx, r.derivativeRoot)
	if err != nil {
		return false, err
	}
	if !r.opts.PressurePolicy.hard(usage) {
		return false, nil
	}
	if err := r.emergencyReclaim(ctx); err != nil {
		return false, err
	}
	if err := sweepTrash(r.derivativeRoot); err != nil {
		return false, err
	}
	usage, err = r.opts.DiskUsageProvider.Measure(ctx, r.derivativeRoot)
	if err != nil {
		return false, err
	}
	return r.opts.PressurePolicy.hard(usage), nil
}

type pressureCandidate struct {
	basename string
	priority int
	age      time.Time
}

const (
	pressureExpiredRollback = iota
	pressureFailedOrTemp
	pressureUnexpiredRollback
)

func (r *Reconciler) emergencyReclaim(ctx context.Context) error {
	r.retentionMu.Lock()
	defer r.retentionMu.Unlock()

	candidates, err := r.pressureCandidates(ctx)
	if err != nil {
		return err
	}
	for _, candidate := range candidates {
		if err := ctx.Err(); err != nil {
			return err
		}
		if err := r.retireAndTrashGeneration(ctx, candidate.basename); err != nil {
			return fmt.Errorf("memorysearch: emergency reclaim %s: %w",
				candidate.basename, err)
		}
		// Physical trash contributes to the same per-agent cap. Sweep it
		// synchronously before re-measuring so reclaim makes observable
		// progress and cannot spin on renamed bytes.
		if err := sweepTrash(r.derivativeRoot); err != nil {
			return err
		}
		usage, err := r.opts.DiskUsageProvider.Measure(ctx, r.derivativeRoot)
		if err != nil {
			return err
		}
		if !r.opts.PressurePolicy.hard(usage) {
			return nil
		}
	}
	return nil
}

func (r *Reconciler) pressureCandidates(
	ctx context.Context,
) ([]pressureCandidate, error) {
	current, _ := loadCurrent(r.derivativeRoot)
	pending, _ := loadPending(r.derivativeRoot)
	state, stateErr := r.lifecycle.load()
	now := r.clock()
	entries, err := os.ReadDir(r.generationsDir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	candidates := make([]pressureCandidate, 0, len(entries))
	for _, entry := range entries {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		if !entry.IsDir() || validateBasename(entry.Name()) != nil {
			continue
		}
		name := entry.Name()
		if current != nil &&
			current.BindingFingerprint == r.opts.Binding.Fingerprint() &&
			current.GenerationBasename == name {
			continue
		}
		if pending != nil && pending.CandidateBasename == name {
			continue
		}
		info, err := entry.Info()
		if err != nil {
			continue
		}
		if _, err := os.Stat(filepath.Join(
			r.generationsDir,
			name,
			buildCompleteMarker,
		)); err != nil {
			if r.buildsInFlight > 0 {
				continue
			}
			// pressureMu plus buildsInFlight proves this Reconciler has no
			// active Build, and pending.CandidateBasename protected any
			// candidate selected for publish. Hard pressure may therefore
			// reclaim markerless failed/temp work before the ordinary one-hour
			// retention boundary; soft pressure never reaches this path.
			candidates = append(candidates, pressureCandidate{
				basename: name,
				priority: pressureFailedOrTemp,
				age:      info.ModTime(),
			})
			continue
		}
		if stateErr != nil {
			continue
		}
		generation, ok := state.Generations[name]
		if !ok || generation.Kind != GenerationLifecycleRollback {
			// Unknown completed generations are conservative skips. A later
			// lifecycle repair can classify them without risking a candidate
			// that publish has not yet recorded.
			continue
		}
		priority := pressureUnexpiredRollback
		if generationExpired(state, name, now) {
			priority = pressureExpiredRollback
		}
		candidates = append(candidates, pressureCandidate{
			basename: name,
			priority: priority,
			age:      generation.PublishedAt,
		})
	}
	sort.Slice(candidates, func(i, j int) bool {
		if candidates[i].priority != candidates[j].priority {
			return candidates[i].priority < candidates[j].priority
		}
		return candidates[i].age.Before(candidates[j].age)
	})
	return candidates, nil
}

func (p PressurePolicy) hard(usage DiskUsage) bool {
	return usage.Bytes >= p.HardBytes ||
		(usage.TotalBytes > 0 && usage.FreeBytes < p.HardFreeBytes)
}

type osDiskUsageProvider struct{}

func (osDiskUsageProvider) Measure(
	ctx context.Context,
	derivativeRoot string,
) (DiskUsage, error) {
	var usage DiskUsage
	err := filepath.WalkDir(derivativeRoot, func(
		path string,
		entry fs.DirEntry,
		walkErr error,
	) error {
		if walkErr != nil {
			if errors.Is(walkErr, os.ErrNotExist) &&
				filepath.Clean(path) == filepath.Clean(derivativeRoot) {
				return fs.SkipDir
			}
			return walkErr
		}
		if err := ctx.Err(); err != nil {
			return err
		}
		if entry.IsDir() {
			return nil
		}
		info, err := entry.Info()
		if err != nil {
			return err
		}
		if info.Size() > 0 {
			usage.Bytes += uint64(info.Size())
		}
		return nil
	})
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return DiskUsage{}, fmt.Errorf("memorysearch: measure derivative: %w", err)
	}
	free, total, err := volumeUsage(nearestExistingPath(derivativeRoot))
	if err != nil {
		return DiskUsage{}, err
	}
	usage.FreeBytes = free
	usage.TotalBytes = total
	return usage, nil
}

func nearestExistingPath(path string) string {
	for current := filepath.Clean(path); ; current = filepath.Dir(current) {
		if _, err := os.Stat(current); err == nil {
			return current
		}
		parent := filepath.Dir(current)
		if parent == current {
			return current
		}
	}
}
