//go:build !vectors

package memorysearch

import (
	"context"
	"os"
	"path/filepath"
	"testing"
	"time"

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

func TestGenerationExpiredRollbackDeadlines(t *testing.T) {
	superseded := time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC)
	firstQuery := superseded.Add(10 * time.Minute)
	state := &LifecycleState{
		BindingState: BindingLifecycleActive,
		Generations: map[string]GenerationLifecycle{
			"old": {
				Kind:         GenerationLifecycleRollback,
				PublishedAt:  superseded.Add(-time.Hour),
				SupersededAt: &superseded,
			},
			"new": {
				Kind:                   GenerationLifecycleCurrent,
				PublishedAt:            superseded,
				FirstSuccessfulQueryAt: &firstQuery,
			},
		},
	}
	require.False(t, generationExpired(state, "old",
		firstQuery.Add(59*time.Minute)))
	require.True(t, generationExpired(state, "old",
		firstQuery.Add(time.Hour)))

	state.Generations["new"] = GenerationLifecycle{
		Kind:        GenerationLifecycleCurrent,
		PublishedAt: superseded,
	}
	require.False(t, generationExpired(state, "old",
		superseded.Add(23*time.Hour+59*time.Minute)))
	require.True(t, generationExpired(state, "old",
		superseded.Add(24*time.Hour)))
}

func TestGenerationExpiredBindingStates(t *testing.T) {
	changed := time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC)
	state := &LifecycleState{
		BindingChangedAt: changed,
		Generations:      map[string]GenerationLifecycle{},
	}
	state.BindingState = BindingLifecycleFenced
	require.False(t, generationExpired(state, "x", changed.Add(23*time.Hour)))
	require.True(t, generationExpired(state, "x", changed.Add(24*time.Hour)))
	state.BindingState = BindingLifecycleUnbound
	require.False(t, generationExpired(state, "x", changed.Add(6*24*time.Hour)))
	require.True(t, generationExpired(state, "x", changed.Add(7*24*time.Hour)))
	state.BindingState = BindingLifecycleUnknownOwner
	require.True(t, generationExpired(state, "x", changed.Add(7*24*time.Hour)))
}

func TestMoveGenerationToTrashAndRestartSweep(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nbody\n",
	})
	generation := filepath.Join(h.derivativeRoot, generationsDirName, "gen-a")
	require.NoError(t, os.MkdirAll(generation, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(generation, "payload"), []byte("x"), 0o600))
	trashed, err := moveGenerationToTrash(h.derivativeRoot, generation, time.Now())
	require.NoError(t, err)
	require.NoDirExists(t, generation)
	require.DirExists(t, trashed)
	require.NoError(t, sweepTrash(h.derivativeRoot))
	require.NoDirExists(t, trashed)
}

func TestRetentionGCKeepsCurrentAndTrashesExpiredRollback(t *testing.T) {
	r, fixture := reconcileHarness(t)
	now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
	r.clock = func() time.Time { return now }
	r.lifecycle.clock = r.clock
	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")

	rollbackName := "rollback-old"
	rollbackDir := filepath.Join(r.generationsDir, rollbackName)
	require.NoError(t, os.MkdirAll(rollbackDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(rollbackDir, buildCompleteMarker), nil, 0o600))
	superseded := now.Add(-25 * time.Hour)
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	state.Generations[rollbackName] = GenerationLifecycle{
		Kind:         GenerationLifecycleRollback,
		PublishedAt:  superseded.Add(-time.Hour),
		SupersededAt: &superseded,
	}
	require.NoError(t, r.lifecycle.replaceForTest(state))

	require.NoError(t, r.RunRetentionGC(context.Background()))
	require.DirExists(t, filepath.Join(r.generationsDir, current.GenerationBasename))
	require.NoDirExists(t, rollbackDir)
	_ = fixture
}

func TestManagerFenceAndPurge(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	waitForServiceableCurrent(t, r, 5*time.Second)
	fp := h.binding.Fingerprint()

	require.NoError(t, m.FenceBinding(context.Background(), fp))
	require.Equal(t, "", m.ActiveFingerprint(h.binding.LocalAgentID))
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	require.Equal(t, BindingLifecycleFenced, state.BindingState)

	// A fenced binding may be recreated only after the old owner lock closed.
	r2, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotSame(t, r, r2)
	require.NoError(t, m.PurgeBinding(context.Background(), fp))
	require.NoDirExists(t, filepath.Join(h.derivativeRoot, generationsDirName))
	require.NoFileExists(t, filepath.Join(h.derivativeRoot, lifecycleFileName))
}

func TestManagerUnboundAndUnknownOwnerMaintenanceTTL(t *testing.T) {
	for _, tc := range []struct {
		name  string
		state BindingLifecycleState
		ttl   time.Duration
		mark  func(*Manager, context.Context, string) error
	}{
		{
			name:  "unbound",
			state: BindingLifecycleUnbound,
			ttl:   unboundBindingRetention,
			mark:  (*Manager).MarkUnbound,
		},
		{
			name:  "unknown-owner",
			state: BindingLifecycleUnknownOwner,
			ttl:   unknownOwnerRetention,
			mark:  (*Manager).MarkUnknownOwner,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			now := time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC)
			m, h := managerHarness(t, ManagerOptions{
				Clock:               func() time.Time { return now },
				MaintenanceInterval: -1,
			})
			r, err := m.GetOrCreate(context.Background(), h.binding)
			require.NoError(t, err)
			waitForServiceableCurrent(t, r, 5*time.Second)
			require.NoError(t, tc.mark(m, context.Background(), h.binding.Fingerprint()))
			state, err := r.lifecycle.load()
			require.NoError(t, err)
			require.Equal(t, tc.state, state.BindingState)

			now = now.Add(tc.ttl - time.Minute)
			require.NoError(t, m.RunMaintenance(context.Background()))
			require.DirExists(t, h.derivativeRoot)

			now = now.Add(2 * time.Minute)
			require.NoError(t, m.RunMaintenance(context.Background()))
			require.NoDirExists(t, filepath.Join(h.derivativeRoot, generationsDirName))
		})
	}
}

func TestManagerPurgeAgentWithoutActiveHandle(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{MaintenanceInterval: -1})
	require.NoError(t, os.MkdirAll(filepath.Join(h.derivativeRoot, generationsDirName, "orphan"), 0o755))
	require.NoError(t, m.PurgeAgent(context.Background(), h.binding.LocalAgentID))
	require.NoDirExists(t, filepath.Join(h.derivativeRoot, generationsDirName))
}

func TestManagerPurgeAgentWithoutDerivativeRoot(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{MaintenanceInterval: -1})
	require.NoError(t, os.RemoveAll(h.derivativeRoot))
	require.NoError(t, m.PurgeAgent(context.Background(), h.binding.LocalAgentID))
}

func TestManagerCanceledFenceIsNoOpAndDoesNotLeakOwner(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{MaintenanceInterval: -1})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	waitForServiceableCurrent(t, r, 5*time.Second)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	err = m.FenceBinding(ctx, h.binding.Fingerprint())
	require.ErrorIs(t, err, context.Canceled)
	require.Equal(t, h.binding.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID))
	require.Same(t, r, func() *Reconciler {
		got, getErr := m.GetOrCreate(context.Background(), h.binding)
		require.NoError(t, getErr)
		return got
	}())

	require.NoError(t, m.FenceBinding(context.Background(), h.binding.Fingerprint()))
	replacement, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotSame(t, r, replacement)
}

func TestRetentionGCDefersActiveBorrowWithoutBlocking(t *testing.T) {
	r, _ := reconcileHarness(t)
	now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
	r.clock = func() time.Time { return now }
	r.lifecycle.clock = r.clock
	require.NoError(t, r.Start(context.Background()))
	current := waitForServiceableCurrent(t, r, 5*time.Second)
	borrow, err := r.Current(context.Background())
	require.NoError(t, err)

	// Make the borrowed current look like an expired rollback while keeping a
	// different authoritative current pointer. GC must mark retire and return
	// without waiting for this long-lived borrower.
	newCurrent := Current{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		GenerationBasename: "new-current",
		SourceRevision:     "new-revision",
		PublishedAt:        now,
	}
	require.NoError(t, writeCurrentAtomic(r.derivativeRoot, newCurrent))
	superseded := now.Add(-25 * time.Hour)
	state, err := r.lifecycle.load()
	require.NoError(t, err)
	old := state.Generations[current.GenerationBasename]
	old.Kind = GenerationLifecycleRollback
	old.SupersededAt = &superseded
	state.Generations[current.GenerationBasename] = old
	state.Generations[newCurrent.GenerationBasename] = GenerationLifecycle{
		Kind:        GenerationLifecycleCurrent,
		PublishedAt: now,
	}
	require.NoError(t, r.lifecycle.replaceForTest(state))

	done := make(chan error, 1)
	go func() { done <- r.RunRetentionGC(context.Background()) }()
	select {
	case err := <-done:
		require.NoError(t, err)
	case <-time.After(time.Second):
		t.Fatal("retention GC blocked on active query")
	}
	require.DirExists(t, borrow.Dir())
	require.NoError(t, borrow.Close())
	require.Eventually(t, func() bool {
		return r.RunRetentionGC(context.Background()) == nil &&
			func() bool {
				_, err := os.Stat(borrow.Dir())
				return os.IsNotExist(err)
			}()
	}, 2*time.Second, 20*time.Millisecond)
}
