package executor

import (
	"container/list"
	"sync"
	"time"

	"go.uber.org/zap"
)

const (
	// defaultPreparedStoreMax bounds the per-tenant CQL prepared statement cache
	// when no explicit limit is configured. The cache is content-addressed by
	// sha256(keyspace+query); evicting an entry only costs the client a transparent
	// re-prepare via the CQL UNPREPARED recovery path, so a generous cap is safe.
	defaultPreparedStoreMax = 10000
	// defaultPreparedStoreIdleTTL drops entries not used within the TTL on next access.
	defaultPreparedStoreIdleTTL = time.Hour
)

// preparedEntry is the LRU list payload: a cached statement plus its recency stamp.
type preparedEntry struct {
	stmt    *PreparedStatement
	lastUse time.Time
}

// preparedStore is a bounded, content-addressed cache of CQL prepared statements
// shared across a tenant's connections. It evicts by LRU once it exceeds max and
// drops idle entries lazily on access (no background goroutine). Eviction is always
// safe: a missing id surfaces as UNPREPARED and the driver re-prepares transparently.
type preparedStore struct {
	mu      sync.Mutex
	stmts   map[string]*list.Element // id -> *list.Element holding *preparedEntry
	lru     *list.List               // front = most recently used
	max     int                      // 0 disables the size cap
	idleTTL time.Duration            // 0 disables idle expiry
	logger  *zap.Logger
	nowFn   func() time.Time // injectable clock for tests
}

// newPreparedStore builds a store with default bounds.
func newPreparedStore(logger *zap.Logger) *preparedStore {
	return newPreparedStoreWithConfig(logger, defaultPreparedStoreMax, defaultPreparedStoreIdleTTL)
}

// newPreparedStoreWithConfig builds a store with the provided size cap and idle TTL.
// A non-positive max clamps to the default cap so the cache can never grow unbounded;
// a negative idle TTL is treated as disabled.
func newPreparedStoreWithConfig(logger *zap.Logger, max int, idleTTL time.Duration) *preparedStore {
	if logger == nil {
		logger = zap.NewNop()
	}
	if max <= 0 {
		max = defaultPreparedStoreMax
	}
	if idleTTL < 0 {
		idleTTL = 0
	}
	return &preparedStore{
		stmts:   make(map[string]*list.Element),
		lru:     list.New(),
		max:     max,
		idleTTL: idleTTL,
		logger:  logger,
		nowFn:   time.Now,
	}
}

func (s *preparedStore) now() time.Time {
	if s.nowFn != nil {
		return s.nowFn()
	}
	return time.Now()
}

func (s *preparedStore) isExpired(entry *preparedEntry, now time.Time) bool {
	return s.idleTTL > 0 && now.Sub(entry.lastUse) > s.idleTTL
}

// get returns the prepared statement for id and refreshes its recency. An expired
// entry is removed and reported as a miss, so the caller surfaces UNPREPARED and the
// driver re-prepares. Removing on read (rather than leaving the stale entry) is
// required: otherwise the subsequent re-prepare would hit putIfAbsent's "already
// present" branch and the entry would stay expired forever, looping re-prepares.
func (s *preparedStore) get(id string) (*PreparedStatement, bool) {
	if s == nil {
		return nil, false
	}
	now := s.now()
	s.mu.Lock()
	defer s.mu.Unlock()
	el, ok := s.stmts[id]
	if !ok {
		return nil, false
	}
	entry := el.Value.(*preparedEntry)
	if s.isExpired(entry, now) {
		s.removeElementLocked(el)
		return nil, false
	}
	entry.lastUse = now
	s.lru.MoveToFront(el)
	return entry.stmt, true
}

// putIfAbsent stores stmt unless an entry with the same ID already exists. On a hit
// it refreshes recency (lastUse + MoveToFront) so a re-prepare that races ahead of
// get's lazy expiry cannot leave a stale, already-expired entry behind.
func (s *preparedStore) putIfAbsent(stmt *PreparedStatement) (*PreparedStatement, bool) {
	if s == nil {
		return stmt, true
	}
	if stmt == nil {
		return nil, false
	}
	now := s.now()
	s.mu.Lock()
	defer s.mu.Unlock()
	if el, ok := s.stmts[stmt.ID]; ok {
		entry := el.Value.(*preparedEntry)
		entry.lastUse = now
		s.lru.MoveToFront(el)
		return entry.stmt, false
	}
	entry := &preparedEntry{stmt: stmt, lastUse: now}
	s.stmts[stmt.ID] = s.lru.PushFront(entry)
	s.trimLocked()
	return stmt, true
}

// trimLocked evicts least-recently-used entries until the cache is within max.
func (s *preparedStore) trimLocked() {
	if s.max <= 0 {
		return
	}
	for len(s.stmts) > s.max {
		el := s.lru.Back()
		if el == nil {
			return
		}
		s.removeElementLocked(el)
	}
}

func (s *preparedStore) removeElementLocked(el *list.Element) {
	entry := el.Value.(*preparedEntry)
	delete(s.stmts, entry.stmt.ID)
	s.lru.Remove(el)
}

func (s *preparedStore) remove(id string) {
	if s == nil {
		return
	}
	s.mu.Lock()
	if el, ok := s.stmts[id]; ok {
		s.removeElementLocked(el)
	}
	s.mu.Unlock()
}

func (s *preparedStore) Clear() {
	if s == nil {
		return
	}
	s.mu.Lock()
	s.stmts = make(map[string]*list.Element)
	s.lru.Init()
	s.mu.Unlock()
}

// cleanup eagerly drops entries idle for longer than olderThan. The store expires
// lazily on access and bounds memory by LRU, so this is optional; it exists for the
// Executor.Cleanup hook and for callers that want to reclaim memory proactively.
func (s *preparedStore) cleanup(olderThan time.Duration) {
	if s == nil || olderThan <= 0 {
		return
	}
	cutoff := s.now().Add(-olderThan)
	s.mu.Lock()
	// The LRU list is ordered most-recent at the front, so once we reach an entry
	// newer than the cutoff every remaining entry is newer too.
	for el := s.lru.Back(); el != nil; {
		entry := el.Value.(*preparedEntry)
		if !entry.lastUse.Before(cutoff) {
			break
		}
		prev := el.Prev()
		s.removeElementLocked(el)
		if s.logger != nil {
			s.logger.Debug("Cleaned up prepared statement", zap.String("id", entry.stmt.ID))
		}
		el = prev
	}
	s.mu.Unlock()
}

// size reports the number of cached statements (introspection / tests).
func (s *preparedStore) size() int {
	if s == nil {
		return 0
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.stmts)
}
