//go:build !vectors

package memorysearch

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

type fakeDiskUsageProvider struct {
	mu      sync.Mutex
	usage   DiskUsage
	err     error
	calls   int
	onCall  func(int)
	dynamic func() DiskUsage
}

func (f *fakeDiskUsageProvider) Measure(
	ctx context.Context,
	_ string,
) (DiskUsage, error) {
	if err := ctx.Err(); err != nil {
		return DiskUsage{}, err
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls++
	if f.onCall != nil {
		f.onCall(f.calls)
	}
	if f.err != nil {
		return DiskUsage{}, f.err
	}
	if f.dynamic != nil {
		return f.dynamic(), nil
	}
	return f.usage, nil
}

func (f *fakeDiskUsageProvider) callCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.calls
}

func pressureTestPolicy() PressurePolicy {
	return PressurePolicy{
		SoftBytes:       100,
		HardBytes:       200,
		SoftFreePercent: 10,
		HardFreeBytes:   50,
	}
}

func TestPressurePolicyBoundaries(t *testing.T) {
	policy := pressureTestPolicy()
	require.False(t, policy.soft(DiskUsage{Bytes: 99, FreeBytes: 10, TotalBytes: 100}))
	require.True(t, policy.soft(DiskUsage{Bytes: 100, FreeBytes: 10, TotalBytes: 100}))
	require.True(t, policy.soft(DiskUsage{Bytes: 1, FreeBytes: 9, TotalBytes: 100}))
	require.False(t, policy.hard(DiskUsage{Bytes: 199, FreeBytes: 50, TotalBytes: 100}))
	require.True(t, policy.hard(DiskUsage{Bytes: 200, FreeBytes: 50, TotalBytes: 100}))
	require.True(t, policy.hard(DiskUsage{Bytes: 1, FreeBytes: 49, TotalBytes: 100}))
}

func TestOSDiskUsageProviderCountsCompleteDerivative(t *testing.T) {
	root := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(root, generationsDirName, "g"), 0o700))
	require.NoError(t, os.MkdirAll(filepath.Join(root, trashDirName, "t"), 0o700))
	require.NoError(t, os.WriteFile(filepath.Join(root, generationsDirName, "g", "index"), make([]byte, 11), 0o600))
	require.NoError(t, os.WriteFile(filepath.Join(root, trashDirName, "t", "old"), make([]byte, 13), 0o600))
	require.NoError(t, os.WriteFile(filepath.Join(root, pendingFileName), make([]byte, 17), 0o600))

	usage, err := (osDiskUsageProvider{}).Measure(context.Background(), root)
	require.NoError(t, err)
	require.Equal(t, uint64(41), usage.Bytes)
	require.Positive(t, usage.TotalBytes)
	require.LessOrEqual(t, usage.FreeBytes, usage.TotalBytes)
}

func TestBuildHardAdmissionRunsBeforeLimiter(t *testing.T) {
	provider := &fakeDiskUsageProvider{usage: DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	}}
	limiter := newCountingLimiter(NewBuildLimiter(1))
	var builds atomic.Int32
	m, h := managerHarness(t, ManagerOptions{
		DiskUsageProvider: provider,
		PressurePolicy:    pressureTestPolicy(),
		BuildLimiter:      limiter,
		BuildFunc: func(context.Context, BuildOptions) (*Generation, error) {
			builds.Add(1)
			return nil, errors.New("unexpected build")
		},
	})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)

	require.Eventually(t, func() bool {
		_, err := os.Stat(filepath.Join(r.derivativeRoot, pendingFileName))
		return err == nil
	}, 2*time.Second, 10*time.Millisecond)
	require.Never(t, func() bool {
		return builds.Load() != 0 || limiter.Peak() != 0
	}, 300*time.Millisecond, 10*time.Millisecond)
	require.GreaterOrEqual(t, provider.callCount(), 2)
}

func TestBuildPressureMeasurementFailureFailsOpen(t *testing.T) {
	provider := &fakeDiskUsageProvider{err: errors.New("statfs unavailable")}
	var builds atomic.Int32
	m, h := managerHarness(t, ManagerOptions{
		DiskUsageProvider: provider,
		PressurePolicy:    pressureTestPolicy(),
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			builds.Add(1)
			return Build(ctx, opts)
		},
	})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	waitForServiceableCurrent(t, r, 5*time.Second)
	require.Equal(t, int32(1), builds.Load())
}

func TestSoftPressureDoesNotDeleteUnexpiredRollback(t *testing.T) {
	r, _ := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      150,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	rollback := plantRollbackForPressure(t, r, "young-rollback", r.clock().Add(-time.Hour))

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.DirExists(t, rollback)
}

func TestSoftPressureKeepsYoungFailedGeneration(t *testing.T) {
	r, _ := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      150,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	failed := filepath.Join(r.generationsDir, "young-failed")
	require.NoError(t, os.MkdirAll(failed, 0o700))

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.DirExists(t, failed)
}

func TestHardPressureReclaimsYoungFailedGenerationWhenNoBuildInFlight(t *testing.T) {
	r, provider := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	failed := filepath.Join(r.generationsDir, "young-failed")
	require.NoError(t, os.MkdirAll(failed, 0o700))
	provider.dynamic = func() DiskUsage {
		if _, err := os.Stat(failed); errors.Is(err, os.ErrNotExist) {
			return DiskUsage{Bytes: 100, FreeBytes: 100, TotalBytes: 1000}
		}
		return DiskUsage{Bytes: 300, FreeBytes: 100, TotalBytes: 1000}
	}

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.NoDirExists(t, failed)
}

func TestHardPressureDeletesUnexpiredRollbackButKeepsCurrent(t *testing.T) {
	r, provider := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	current := waitForServiceableCurrent(t, r, 5*time.Second)
	rollback := plantRollbackForPressure(t, r, "young-rollback", r.clock().Add(-time.Hour))
	provider.dynamic = func() DiskUsage {
		if _, err := os.Stat(rollback); errors.Is(err, os.ErrNotExist) {
			return DiskUsage{Bytes: 100, FreeBytes: 100, TotalBytes: 1000}
		}
		return DiskUsage{Bytes: 300, FreeBytes: 100, TotalBytes: 1000}
	}

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.NoDirExists(t, rollback)
	require.DirExists(t, filepath.Join(r.generationsDir, current.GenerationBasename))
}

func TestHardPressureWithOnlyCurrentRejectsBuildWithoutDeletingCurrent(t *testing.T) {
	r, _ := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	current := waitForServiceableCurrent(t, r, 5*time.Second)

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.True(t, hard)
	require.DirExists(t, filepath.Join(r.generationsDir, current.GenerationBasename))
	gen, err := r.Current(context.Background())
	require.NoError(t, err, "hard pressure must not disable current search")
	require.NoError(t, gen.Close())
}

func TestEmergencyReclaimDefersActiveBorrow(t *testing.T) {
	r, provider := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	current := waitForServiceableCurrent(t, r, 5*time.Second)
	borrow, err := r.Current(context.Background())
	require.NoError(t, err)
	rollbackPath := borrow.Dir()
	publishFakeCurrentForPressure(t, r, "replacement-current", current)
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	superseded := r.clock().Add(-25 * time.Hour)
	old := state.Generations[current.GenerationBasename]
	old.Kind = GenerationLifecycleRollback
	old.SupersededAt = &superseded
	state.Generations[current.GenerationBasename] = old
	require.NoError(t, r.lifecycle.replaceForTest(state))

	done := make(chan struct{})
	go func() {
		_, _ = r.RelieveDiskPressure(context.Background())
		close(done)
	}()
	require.Eventually(t, func() bool {
		select {
		case <-done:
			return true
		default:
			return false
		}
	}, time.Second, 10*time.Millisecond)
	require.DirExists(t, rollbackPath)
	require.NoError(t, borrow.Close())
	provider.dynamic = func() DiskUsage {
		if _, err := os.Stat(rollbackPath); errors.Is(err, os.ErrNotExist) {
			return DiskUsage{Bytes: 100, FreeBytes: 100, TotalBytes: 1000}
		}
		return DiskUsage{Bytes: 300, FreeBytes: 100, TotalBytes: 1000}
	}
	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.NoDirExists(t, rollbackPath)
}

func TestEmergencyReclaimDoesNotDeleteNewCurrentDuringPublish(t *testing.T) {
	r, _ := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	previous := waitForServiceableCurrent(t, r, 5*time.Second)
	target := plantRollbackForPressure(t, r, "publish-target", r.clock().Add(-25*time.Hour))

	r.namespaceMu.Lock()
	done := make(chan error, 1)
	go func() {
		_, err := r.RelieveDiskPressure(context.Background())
		done <- err
	}()
	require.Never(t, func() bool {
		select {
		case <-done:
			return true
		default:
			return false
		}
	}, 100*time.Millisecond, 10*time.Millisecond)

	current := Current{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		GenerationBasename: filepath.Base(target),
		SourceRevision:     "published-during-reclaim",
		PublishedAt:        r.clock(),
	}
	require.NoError(t, writeCurrentAtomic(r.derivativeRoot, current))
	r.namespaceMu.Unlock()

	require.NoError(t, <-done)
	require.DirExists(t, target)
	require.DirExists(t, filepath.Join(r.generationsDir, previous.GenerationBasename))
}

func TestHardAdmissionDoesNotInterruptInflightBuild(t *testing.T) {
	provider := &fakeDiskUsageProvider{usage: DiskUsage{
		Bytes:      1,
		FreeBytes:  100,
		TotalBytes: 1000,
	}}
	buildStarted := make(chan struct{})
	releaseBuild := make(chan struct{})
	var starts atomic.Int32
	m, h := managerHarness(t, ManagerOptions{
		DiskUsageProvider: provider,
		PressurePolicy:    pressureTestPolicy(),
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			if starts.Add(1) == 1 {
				close(buildStarted)
				select {
				case <-releaseBuild:
				case <-ctx.Done():
					return nil, ctx.Err()
				}
			}
			return Build(ctx, opts)
		},
	})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	<-buildStarted

	provider.mu.Lock()
	provider.usage = DiskUsage{Bytes: 300, FreeBytes: 100, TotalBytes: 1000}
	provider.mu.Unlock()
	pressureDone := make(chan error, 1)
	go func() {
		_, err := r.RelieveDiskPressure(context.Background())
		pressureDone <- err
	}()
	require.NoError(t, <-pressureDone)
	// Pressure maintenance may finish while the build is parked, but must not
	// cancel or delete the in-flight build. Only releasing the build gate lets
	// the original reconcile publish.
	require.Equal(t, int32(1), starts.Load())
	close(releaseBuild)
	waitForServiceableCurrent(t, r, 5*time.Second)
	require.Equal(t, int32(1), starts.Load())
}

func TestHardPressureReclaimsExpiredRollbackBeforeYoungFailed(t *testing.T) {
	r, provider := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	expired := plantRollbackForPressure(t, r, "expired-rollback", r.clock().Add(-25*time.Hour))
	failed := filepath.Join(r.generationsDir, "young-failed")
	require.NoError(t, os.MkdirAll(failed, 0o700))
	provider.dynamic = func() DiskUsage {
		if _, err := os.Stat(expired); errors.Is(err, os.ErrNotExist) {
			return DiskUsage{Bytes: 100, FreeBytes: 100, TotalBytes: 1000}
		}
		return DiskUsage{Bytes: 300, FreeBytes: 100, TotalBytes: 1000}
	}

	hard, err := r.RelieveDiskPressure(context.Background())
	require.NoError(t, err)
	require.False(t, hard)
	require.NoDirExists(t, expired)
	require.DirExists(t, failed)
}

func TestPressureReclaimRetentionAndTrashSweepAreConcurrentIdempotent(t *testing.T) {
	r, _ := pressureReconcilerHarness(t, DiskUsage{
		Bytes:      300,
		FreeBytes:  100,
		TotalBytes: 1000,
	})
	plantRollbackForPressure(t, r, "expired-rollback", r.clock().Add(-25*time.Hour))
	trashEntry := filepath.Join(r.derivativeRoot, trashDirName, "old")
	require.NoError(t, os.MkdirAll(trashEntry, 0o700))
	require.NoError(t, os.WriteFile(filepath.Join(trashEntry, "payload"), []byte("x"), 0o600))

	const workers = 12
	errs := make(chan error, workers)
	for i := 0; i < workers; i++ {
		go func(i int) {
			switch i % 3 {
			case 0:
				_, err := r.RelieveDiskPressure(context.Background())
				errs <- err
			case 1:
				errs <- r.RunRetentionGC(context.Background())
			default:
				errs <- sweepTrash(r.derivativeRoot)
			}
		}(i)
	}
	for i := 0; i < workers; i++ {
		require.NoError(t, <-errs)
	}
	current := waitForServiceableCurrent(t, r, 5*time.Second)
	require.DirExists(t, filepath.Join(r.generationsDir, current.GenerationBasename))
}

func pressureReconcilerHarness(
	t *testing.T,
	usage DiskUsage,
) (*Reconciler, *fakeDiskUsageProvider) {
	t.Helper()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\npressure\n",
	})
	provider := &fakeDiskUsageProvider{usage: DiskUsage{
		Bytes:      1,
		FreeBytes:  100,
		TotalBytes: 1000,
	}}
	r, err := NewReconciler(ReconcilerOptions{
		Binding:           h.binding,
		Resolver:          h.resolver,
		Scan:              Scan,
		DiskUsageProvider: provider,
		PressurePolicy:    pressureTestPolicy(),
	})
	require.NoError(t, err)
	require.NoError(t, r.Start(context.Background()))
	current := waitForServiceableCurrent(t, r, 5*time.Second)
	require.Eventually(t, func() bool {
		state, err := r.lifecycle.load()
		if err != nil {
			return false
		}
		generation, ok := state.Generations[current.GenerationBasename]
		return ok && generation.Kind == GenerationLifecycleCurrent
	}, 5*time.Second, 10*time.Millisecond,
		"lifecycle metadata must follow the published current")
	provider.usage = usage
	t.Cleanup(func() { _ = r.Close() })
	return r, provider
}

func plantRollbackForPressure(
	t *testing.T,
	r *Reconciler,
	basename string,
	superseded time.Time,
) string {
	t.Helper()
	dir := filepath.Join(r.generationsDir, basename)
	require.NoError(t, os.MkdirAll(dir, 0o700))
	require.NoError(t, os.WriteFile(filepath.Join(dir, buildCompleteMarker), nil, 0o600))
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	state.Generations[basename] = GenerationLifecycle{
		Kind:         GenerationLifecycleRollback,
		PublishedAt:  superseded.Add(-time.Hour),
		SupersededAt: &superseded,
	}
	require.NoError(t, r.lifecycle.replaceForTest(state))
	return dir
}

func publishFakeCurrentForPressure(
	t *testing.T,
	r *Reconciler,
	basename string,
	previous *Current,
) {
	t.Helper()
	dir := filepath.Join(r.generationsDir, basename)
	require.NoError(t, os.MkdirAll(dir, 0o700))
	require.NoError(t, os.WriteFile(filepath.Join(dir, buildCompleteMarker), nil, 0o600))
	current := Current{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		GenerationBasename: basename,
		SourceRevision:     "replacement",
		PublishedAt:        r.clock(),
	}
	r.namespaceMu.Lock()
	require.NoError(t, writeCurrentAtomic(r.derivativeRoot, current))
	r.namespaceMu.Unlock()
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	state.Generations[basename] = GenerationLifecycle{
		Kind:        GenerationLifecycleCurrent,
		PublishedAt: current.PublishedAt,
	}
	if previous != nil {
		old := state.Generations[previous.GenerationBasename]
		old.Kind = GenerationLifecycleRollback
		superseded := current.PublishedAt
		old.SupersededAt = &superseded
		state.Generations[previous.GenerationBasename] = old
	}
	require.NoError(t, r.lifecycle.replaceForTest(state))
}
