package adapter

import (
	"context"
	"strconv"
	"sync"
)

// maxTrackedRuns bounds the dedup table. Entries are only consulted while
// alertstore is still retrying one dispatch — a window of minutes — so anything
// beyond the most recent few hundred runs is dead weight. Run IDs are
// auto-increment and never reused, so an evicted entry can only ever cost a
// duplicate session on a very late retry, never a wrong answer.
const maxTrackedRuns = 1024

// sessionTracker makes /dispatch idempotent per run.
//
// It exists because the two sides disagree about what a timeout means.
// alertstore's outbox is idempotent by design: a retried dispatch carries the
// same run_id, because EnsureRun deliberately returns the run it already
// created. The adapter, though, used to start a usher session unconditionally —
// so a dispatch that succeeded slowly (usher spawning an agent process can
// outlast alertstore's ten-second timeout) came back and started a second one.
//
// The cost is not just the wasted work. Both sessions report against the same
// run, and alertstore's report merge takes the last non-empty value for each
// field — two agents investigating the same alert in parallel, with whichever
// finishes second silently overwriting the other's verdict and no record that
// there were ever two.
type sessionTracker struct {
	mu      sync.Mutex
	entries map[string]*sessionEntry
	// order is insertion order, for eviction. A slice rather than a proper LRU
	// because the access pattern is write-once-read-once within minutes.
	order []string
}

// sessionEntry is one run's session, created at most once. once is what makes
// two retries arriving together produce one session rather than racing: the
// second caller blocks on the first's attempt instead of starting its own.
type sessionEntry struct {
	once sync.Once
	id   string
	err  error
}

func newSessionTracker() *sessionTracker {
	return &sessionTracker{entries: make(map[string]*sessionEntry)}
}

func runKey(alertID string, runID int64) string {
	return alertID + "/" + strconv.FormatInt(runID, 10)
}

// start returns the session for this run, creating it on the first call only.
// The second return value reports whether this call is the one that created it,
// which is how the caller knows not to repeat the side effects that go with a
// first dispatch.
//
// A failed creation is not remembered: the entry is dropped so the next retry
// tries again. Caching the failure would turn a transient usher outage into a
// run that can never be dispatched, which is the opposite of what the retry is
// for.
func (t *sessionTracker) start(ctx context.Context, alertID string, runID int64, create func(context.Context) (string, error)) (string, bool, error) {
	key := runKey(alertID, runID)

	t.mu.Lock()
	entry, existing := t.entries[key]
	if !existing {
		entry = &sessionEntry{}
		t.entries[key] = entry
		t.order = append(t.order, key)
		t.evictLocked()
	}
	t.mu.Unlock()

	created := false
	entry.once.Do(func() {
		created = true
		entry.id, entry.err = create(ctx)
	})

	if entry.err != nil {
		// Only the creator clears the entry, and only after every waiter has
		// read the error — which they have, since once.Do has returned for all
		// of them by the time any of them gets here.
		if created {
			t.forget(key)
		}
		return "", created, entry.err
	}
	return entry.id, created, nil
}

func (t *sessionTracker) forget(key string) {
	t.mu.Lock()
	defer t.mu.Unlock()
	delete(t.entries, key)
	for i, k := range t.order {
		if k == key {
			t.order = append(t.order[:i], t.order[i+1:]...)
			break
		}
	}
}

// evictLocked drops the oldest entries once the table is over its cap. Caller
// holds the lock.
func (t *sessionTracker) evictLocked() {
	for len(t.order) > maxTrackedRuns {
		delete(t.entries, t.order[0])
		t.order = t.order[1:]
	}
}
