package lcw

import (
	"sync/atomic"

	"github.com/google/uuid"
	lru "github.com/hashicorp/golang-lru"
	"github.com/pkg/errors"

	"github.com/go-pkgz/lcw/eventbus"
)

// LruCache wraps lru.LruCache with loading cache Get and size limits
type LruCache struct {
	options
	CacheStat
	backend     *lru.Cache
	currentSize int64
	id          string // uuid identifying cache instance
}

// NewLruCache makes LRU LoadingCache implementation, 1000 max keys by default
func NewLruCache(opts ...Option) (*LruCache, error) {
	res := LruCache{
		options: options{
			maxKeys:      1000,
			maxValueSize: 0,
			eventBus:     &eventbus.NopPubSub{},
		},
		id: uuid.New().String(),
	}
	for _, opt := range opts {
		if err := opt(&res.options); err != nil {
			return nil, errors.Wrap(err, "failed to set cache option")
		}
	}

	err := res.init()
	return &res, err
}

func (c *LruCache) init() error {
	if err := c.eventBus.Subscribe(c.onBusEvent); err != nil {
		return errors.Wrapf(err, "can't subscribe to event bus")
	}

	onEvicted := func(key interface{}, value interface{}) {
		if c.onEvicted != nil {
			c.onEvicted(key.(string), value)
		}
		if s, ok := value.(Sizer); ok {
			size := s.Size()
			atomic.AddInt64(&c.currentSize, -1*int64(size))
		}
		_ = c.eventBus.Publish(c.id, key.(string)) // signal invalidation to other nodes
	}

	var err error
	// OnEvicted called automatically for expired and manually deleted
	if c.backend, err = lru.NewWithEvict(c.maxKeys, onEvicted); err != nil {
		return errors.Wrap(err, "failed to make lru cache backend")
	}

	return nil
}

// Get gets value by key or load with fn if not found in cache
func (c *LruCache) Get(key string, fn func() (interface{}, error)) (data interface{}, err error) {
	if v, ok := c.backend.Get(key); ok {
		atomic.AddInt64(&c.Hits, 1)
		return v, nil
	}

	if data, err = fn(); err != nil {
		atomic.AddInt64(&c.Errors, 1)
		return data, err
	}

	atomic.AddInt64(&c.Misses, 1)

	if !c.allowed(key, data) {
		return data, nil
	}

	c.backend.Add(key, data)

	if s, ok := data.(Sizer); ok {
		atomic.AddInt64(&c.currentSize, int64(s.Size()))
		if c.maxCacheSize > 0 && atomic.LoadInt64(&c.currentSize) > c.maxCacheSize {
			for atomic.LoadInt64(&c.currentSize) > c.maxCacheSize {
				c.backend.RemoveOldest()
			}
		}
	}

	return data, nil
}

// Peek returns the key value (or undefined if not found) without updating the "recently used"-ness of the key.
func (c *LruCache) Peek(key string) (interface{}, bool) {
	return c.backend.Peek(key)
}

// Purge clears the cache completely.
func (c *LruCache) Purge() {
	c.backend.Purge()
	atomic.StoreInt64(&c.currentSize, 0)
}

// Invalidate removes keys with passed predicate fn, i.e. fn(key) should be true to get evicted
func (c *LruCache) Invalidate(fn func(key string) bool) {
	for _, k := range c.backend.Keys() { // Keys() returns copy of cache's key, safe to remove directly
		if key, ok := k.(string); ok && fn(key) {
			c.backend.Remove(key)
		}
	}
}

// Delete cache item by key
func (c *LruCache) Delete(key string) {
	c.backend.Remove(key)
}

// Keys returns cache keys
func (c *LruCache) Keys() (res []string) {
	keys := c.backend.Keys()
	res = make([]string, 0, len(keys))
	for _, key := range keys {
		res = append(res, key.(string))
	}
	return res
}

// Stat returns cache statistics
func (c *LruCache) Stat() CacheStat {
	return CacheStat{
		Hits:   c.Hits,
		Misses: c.Misses,
		Size:   c.size(),
		Keys:   c.keys(),
		Errors: c.Errors,
	}
}

// Close does nothing for this type of cache
func (c *LruCache) Close() error {
	return nil
}

// onBusEvent reacts on invalidation message triggered by event bus from another cache instance
func (c *LruCache) onBusEvent(id, key string) {
	if id != c.id && c.backend.Contains(key) { // prevent reaction on event from this cache
		c.backend.Remove(key)
	}
}

func (c *LruCache) size() int64 {
	return atomic.LoadInt64(&c.currentSize)
}

func (c *LruCache) keys() int {
	return c.backend.Len()
}

func (c *LruCache) allowed(key string, data interface{}) bool {
	if c.maxKeySize > 0 && len(key) > c.maxKeySize {
		return false
	}
	if s, ok := data.(Sizer); ok {
		if c.maxValueSize > 0 && s.Size() >= c.maxValueSize {
			return false
		}
	}
	return true
}
