package mysqlbackend

import (
	"container/list"
	"context"
	"database/sql"
	"database/sql/driver"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/go-sql-driver/mysql"
	"go.uber.org/zap"
)

const (
	mysqlErrUnknownStmtHandler      = 1243
	mysqlErrNeedReprepare           = 1615
	mysqlErrMaxPreparedStmtExceeded = 1461
)

// PreparedStmtCacheStats exposes prepared statement cache counters.
type PreparedStmtCacheStats struct {
	Size      int
	Hits      uint64
	Misses    uint64
	Evictions uint64
	Errors    uint64
}

type stmtHandle interface {
	Close() error
}

type stmtPreparer interface {
	PrepareContext(context.Context, string) (stmtHandle, error)
}

type dbStmtPreparer struct {
	db *sql.DB
}

func (p dbStmtPreparer) PrepareContext(ctx context.Context, query string) (stmtHandle, error) {
	if p.db == nil {
		return nil, fmt.Errorf("db is nil")
	}
	return p.db.PrepareContext(ctx, query)
}

type stmtEntry struct {
	sql     string
	stmt    stmtHandle
	lastUse time.Time
	useCnt  uint64
}

type stmtCache struct {
	mu              sync.Mutex
	entries         map[string]*list.Element
	lru             *list.List
	max             int
	idleTTL         time.Duration
	cleanupInterval time.Duration
	logger          *zap.Logger
	stopCh          chan struct{}
	stopOnce        sync.Once
	hits            uint64
	misses          uint64
	evictions       uint64
	errors          uint64
}

func newStmtCache(cfg PreparedStatementsConfig, logger *zap.Logger) *stmtCache {
	if !cfg.Enabled || cfg.MaxStatements <= 0 {
		return nil
	}
	if logger == nil {
		logger = zap.NewNop()
	}
	return &stmtCache{
		entries:         make(map[string]*list.Element, cfg.MaxStatements),
		lru:             list.New(),
		max:             cfg.MaxStatements,
		idleTTL:         cfg.IdleTTL,
		cleanupInterval: cfg.CleanupInterval,
		logger:          logger,
	}
}

func (c *stmtCache) startCleanup() {
	if c == nil || c.cleanupInterval <= 0 {
		return
	}
	c.stopCh = make(chan struct{})
	go func() {
		ticker := time.NewTicker(c.cleanupInterval)
		defer ticker.Stop()
		for {
			select {
			case <-ticker.C:
				c.cleanup()
			case <-c.stopCh:
				return
			}
		}
	}()
}

func (c *stmtCache) stopCleanup() {
	if c == nil || c.stopCh == nil {
		return
	}
	c.stopOnce.Do(func() {
		close(c.stopCh)
	})
}

func (c *stmtCache) prepare(ctx context.Context, prep stmtPreparer, sql string) (stmtHandle, error) {
	if c == nil {
		return prep.PrepareContext(ctx, sql)
	}
	if prep == nil {
		return nil, fmt.Errorf("preparer is required")
	}
	if sql == "" {
		return nil, fmt.Errorf("sql is required")
	}

	now := time.Now()
	var expired stmtHandle
	c.mu.Lock()
	if el, ok := c.entries[sql]; ok {
		entry := el.Value.(*stmtEntry)
		if c.idleTTL > 0 && now.Sub(entry.lastUse) > c.idleTTL {
			expired = c.removeLocked(el)
		} else {
			entry.lastUse = now
			entry.useCnt++
			c.hits++
			c.lru.MoveToFront(el)
			stmt := entry.stmt
			c.mu.Unlock()
			return stmt, nil
		}
	}
	c.misses++
	c.mu.Unlock()
	if expired != nil {
		_ = expired.Close()
	}

	stmt, err := prep.PrepareContext(ctx, sql)
	if err != nil {
		c.recordError()
		if isMaxPreparedStmtError(err) {
			c.evictLRU(1)
			stmt, err = prep.PrepareContext(ctx, sql)
			if err == nil {
				goto store
			}
			c.recordError()
		}
		return nil, err
	}

store:
	var evicted []stmtHandle
	c.mu.Lock()
	if el, ok := c.entries[sql]; ok {
		entry := el.Value.(*stmtEntry)
		entry.lastUse = now
		entry.useCnt++
		c.lru.MoveToFront(el)
		c.mu.Unlock()
		_ = stmt.Close()
		return entry.stmt, nil
	}
	entry := &stmtEntry{
		sql:     sql,
		stmt:    stmt,
		lastUse: now,
		useCnt:  1,
	}
	el := c.lru.PushFront(entry)
	c.entries[sql] = el
	evicted = c.trimLocked()
	c.mu.Unlock()
	for _, handle := range evicted {
		_ = handle.Close()
	}
	return stmt, nil
}

func (c *stmtCache) evict(sql string) {
	if c == nil || sql == "" {
		return
	}
	var handle stmtHandle
	c.mu.Lock()
	if el, ok := c.entries[sql]; ok {
		handle = c.removeLocked(el)
	}
	c.mu.Unlock()
	if handle != nil {
		_ = handle.Close()
	}
}

func (c *stmtCache) evictLRU(n int) {
	if c == nil || n <= 0 {
		return
	}
	var handles []stmtHandle
	c.mu.Lock()
	for i := 0; i < n; i++ {
		el := c.lru.Back()
		if el == nil {
			break
		}
		handles = append(handles, c.removeLocked(el))
	}
	c.mu.Unlock()
	for _, handle := range handles {
		_ = handle.Close()
	}
}

func (c *stmtCache) clear() {
	if c == nil {
		return
	}
	var handles []stmtHandle
	c.mu.Lock()
	for _, el := range c.entries {
		entry := el.Value.(*stmtEntry)
		handles = append(handles, entry.stmt)
	}
	c.entries = make(map[string]*list.Element)
	c.lru.Init()
	c.mu.Unlock()
	for _, handle := range handles {
		_ = handle.Close()
	}
}

func (c *stmtCache) stats() PreparedStmtCacheStats {
	if c == nil {
		return PreparedStmtCacheStats{}
	}
	c.mu.Lock()
	stats := PreparedStmtCacheStats{
		Size:      len(c.entries),
		Hits:      c.hits,
		Misses:    c.misses,
		Evictions: c.evictions,
		Errors:    c.errors,
	}
	c.mu.Unlock()
	return stats
}

func (c *stmtCache) recordError() {
	if c == nil {
		return
	}
	c.mu.Lock()
	c.errors++
	c.mu.Unlock()
}

func (c *stmtCache) cleanup() {
	if c == nil || c.idleTTL <= 0 {
		return
	}
	cutoff := time.Now().Add(-c.idleTTL)
	var handles []stmtHandle
	c.mu.Lock()
	for el := c.lru.Back(); el != nil; {
		entry := el.Value.(*stmtEntry)
		if entry.lastUse.After(cutoff) {
			break
		}
		prev := el.Prev()
		handles = append(handles, c.removeLocked(el))
		el = prev
	}
	c.mu.Unlock()
	for _, handle := range handles {
		_ = handle.Close()
	}
}

func (c *stmtCache) trimLocked() []stmtHandle {
	if c.max <= 0 {
		return nil
	}
	var handles []stmtHandle
	for len(c.entries) > c.max {
		el := c.lru.Back()
		if el == nil {
			break
		}
		handles = append(handles, c.removeLocked(el))
	}
	return handles
}

func (c *stmtCache) removeLocked(el *list.Element) stmtHandle {
	if el == nil {
		return nil
	}
	entry := el.Value.(*stmtEntry)
	delete(c.entries, entry.sql)
	c.lru.Remove(el)
	c.evictions++
	return entry.stmt
}

func isMaxPreparedStmtError(err error) bool {
	var myErr *mysql.MySQLError
	if errors.As(err, &myErr) {
		return myErr.Number == mysqlErrMaxPreparedStmtExceeded
	}
	return false
}

// IsPreparedStmtStale returns true when an error indicates the cached statement is invalid.
func IsPreparedStmtStale(err error) bool {
	if errors.Is(err, driver.ErrBadConn) {
		return true
	}
	var myErr *mysql.MySQLError
	if errors.As(err, &myErr) {
		switch myErr.Number {
		case mysqlErrUnknownStmtHandler, mysqlErrNeedReprepare:
			return true
		}
	}
	return false
}
