package cache

/*
Based on https://github.com/orcaman/concurrent-map
*/

import (
	"fmt"
	"io"
	"sync"
	"sync/atomic"
	"time"

	"github.com/cespare/xxhash/v2"

	"github.com/go-graphite/go-carbon/helper"
	"github.com/go-graphite/go-carbon/points"
	"github.com/go-graphite/go-carbon/tags"
	"github.com/greatroar/blobloom"
)

type WriteStrategy int

const (
	MaximumLength WriteStrategy = iota
	TimestampOrder
	Noop
)

const shardCount = 1024

type cacheSettings struct {
	maxSize     int32
	xlog        io.Writer
	tagsEnabled bool
}

// A "thread" safe map of type string:Anything.
// To avoid lock bottlenecks this map is dived to several (shardCount) map shards.
type Cache struct {
	sync.Mutex

	queueLastBuild time.Time

	data []*Shard

	writeStrategy WriteStrategy
	writeoutQueue *WriteoutQueue

	settings atomic.Value // cacheSettings

	stat struct {
		size                int32  // changing via atomic
		queueBuildCnt       uint32 // number of times writeout queue was built
		queueBuildTimeMs    uint32 // time spent building writeout queue in milliseconds
		queueWriteoutTime   uint32 // in milliseconds
		overflowCnt         uint32 // drop packages if cache full
		queryCnt            uint32 // number of queries
		tagsNormalizeErrors uint32 // tags normalize errors count

		droppedRealtimeIndex uint32 // new metrics failed to be indexed in realtime
	}

	newMetricsChan      chan string
	newMetricCf         *blobloom.Filter
	newMetricCfCapacity uint64

	throttle func(ps *points.Points, inCache bool) bool
}

// A "thread" safe string to anything map.
type Shard struct {
	sync.RWMutex     // Read Write mutex, guards access to internal map.
	items            map[string]*points.Points
	notConfirmed     []*points.Points    // linear search for value/slot
	notConfirmedUsed int                 // search value in notConfirmed[:notConfirmedUsed]
	adds             map[string]struct{} // map to maintain all the new metric names
}

// Creates a new cache instance
func New() *Cache {
	c := &Cache{
		data:          make([]*Shard, shardCount),
		writeStrategy: Noop,
	}

	for i := 0; i < shardCount; i++ {
		c.data[i] = &Shard{
			items:        make(map[string]*points.Points),
			notConfirmed: make([]*points.Points, 4),
		}
	}

	settings := cacheSettings{
		maxSize:     1000000,
		tagsEnabled: false,
		xlog:        nil,
	}

	c.settings.Store(&settings)

	c.writeoutQueue = NewWriteoutQueue(c)
	c.newMetricCf = nil
	return c
}

func (c *Cache) InitCacheScanAdds() {
	for _, shard := range c.data {
		shard.adds = make(map[string]struct{})
	}
}

// SetWriteStrategy ...
func (c *Cache) SetWriteStrategy(s string) (err error) {
	c.Lock()
	defer c.Unlock()

	switch s {
	case "max":
		c.writeStrategy = MaximumLength
	case "sorted":
		c.writeStrategy = TimestampOrder
	case "noop":
		c.writeStrategy = Noop
	default:
		return fmt.Errorf("Unknown write strategy '%s', should be one of: max, sorted, noop", s)
	}
	return nil
}

// SetMaxSize of cache
func (c *Cache) SetMaxSize(maxSize uint32) {
	s := c.settings.Load().(*cacheSettings)
	newSettings := *s
	newSettings.maxSize = int32(maxSize)
	c.settings.Store(&newSettings)
}

// SetBloomSize of bloom filter
func (c *Cache) SetBloomSize(bloomSize uint64) {
	if bloomSize > 0 {
		c.newMetricCf = blobloom.NewOptimized(blobloom.Config{
			Capacity: bloomSize, // Expected number of keys.
			FPRate:   1e-4,      // Accept one false positive per 10,000 lookups.
		})
		c.newMetricCfCapacity = bloomSize
	}
}

func (c *Cache) SetTagsEnabled(value bool) {
	s := c.settings.Load().(*cacheSettings)
	newSettings := *s
	newSettings.tagsEnabled = value
	c.settings.Store(&newSettings)
}

func (c *Cache) SetNewMetricsChan(ch chan string) { c.newMetricsChan = ch }

func (*Cache) Stop() {}

// Collect cache metrics
func (c *Cache) Stat(send helper.StatCallback) {
	s := c.settings.Load().(*cacheSettings)

	send("size", float64(c.Size()))
	send("metrics", float64(c.Len()))
	send("maxSize", float64(s.maxSize))
	send("notConfirmed", float64(c.NotConfirmedLength()))
	// report elements in bloom filter
	if c.newMetricCf != nil {
		cfCount := c.newMetricCf.Cardinality()
		if uint64(cfCount) > c.newMetricCfCapacity {
			// full filter report +Inf cardinality
			cfCount = float64(c.newMetricCfCapacity)
		}
		send("cfCount", cfCount)
	}

	helper.SendAndSubstractUint32("queries", &c.stat.queryCnt, send)
	helper.SendAndSubstractUint32("tagsNormalizeErrors", &c.stat.tagsNormalizeErrors, send)
	helper.SendAndSubstractUint32("overflow", &c.stat.overflowCnt, send)

	helper.SendAndSubstractUint32("queueBuildCount", &c.stat.queueBuildCnt, send)
	helper.SendAndSubstractUint32("queueBuildTimeMs", &c.stat.queueBuildTimeMs, send)
	helper.SendUint32("queueWriteoutTime", &c.stat.queueWriteoutTime, send)

	helper.SendAndSubstractUint32("droppedRealtimeIndex", &c.stat.droppedRealtimeIndex, send)
}

// hash function
// @TODO: try crc32 or something else?
func fnv32(key string) uint32 {
	hash := uint32(2166136261)
	const prime32 = uint32(16777619)
	for i := 0; i < len(key); i++ {
		hash *= prime32
		hash ^= uint32(key[i])
	}
	return hash
}

// GetShard returns shard under given key
func (c *Cache) GetShard(key string) *Shard {
	// @TODO: remove type casts?
	return c.data[uint(fnv32(key))%uint(shardCount)]
}

func (c *Cache) Get(key string) []points.Point {
	atomic.AddUint32(&c.stat.queryCnt, 1)

	shard := c.GetShard(key)

	var data []points.Point
	shard.Lock()
	for _, p := range shard.notConfirmed[:shard.notConfirmedUsed] {
		if p != nil && p.Metric == key {
			if data == nil {
				data = p.Data
			} else {
				data = append(data, p.Data...)
			}
		}
	}

	if p, exists := shard.items[key]; exists {
		if data == nil {
			data = p.Data
		} else {
			data = append(data, p.Data...)
		}
	}
	shard.Unlock()
	return data
}

func (c *Cache) Confirm(p *points.Points) {
	var i, j int
	shard := c.GetShard(p.Metric)

	shard.Lock()
	for i = 0; i < shard.notConfirmedUsed; i++ {
		if shard.notConfirmed[i] == p {
			shard.notConfirmed[i] = nil

			for j = i + 1; j < shard.notConfirmedUsed; j++ {
				shard.notConfirmed[j-1] = shard.notConfirmed[j]
			}

			shard.notConfirmedUsed--
		}
	}
	shard.Unlock()
}

func (c *Cache) Len() int32 {
	l := 0
	for i := 0; i < shardCount; i++ {
		shard := c.data[i]
		shard.Lock()
		l += len(shard.items)
		shard.Unlock()
	}
	return int32(l)
}

func (c *Cache) NotConfirmedLength() int32 {
	l := 0
	for i := 0; i < shardCount; i++ {
		shard := c.data[i]
		shard.Lock()
		l += len(shard.notConfirmed)
		shard.Unlock()
	}
	return int32(l)
}

func (c *Cache) Size() int32 {
	return atomic.LoadInt32(&c.stat.size)
}

func (c *Cache) DivertToXlog(w io.Writer) {
	s := c.settings.Load().(*cacheSettings)
	newSettings := *s
	newSettings.xlog = w
	c.settings.Store(&newSettings)
}

// send metric to the new metrics channel
func sendMetricToNewMetricChan(c *Cache, metric string) {
	select {
	case c.newMetricsChan <- metric:
	default:
		atomic.AddUint32(&c.stat.droppedRealtimeIndex, 1)
	}
}

// Sets the given value under the specified key.
func (c *Cache) Add(p *points.Points) {
	s := c.settings.Load().(*cacheSettings)

	if s.xlog != nil {
		p.WriteTo(s.xlog)
		return
	}

	if s.tagsEnabled {
		var err error
		p.Metric, err = tags.Normalize(p.Metric)
		if err != nil {
			atomic.AddUint32(&c.stat.tagsNormalizeErrors, 1)
			return
		}
	}

	// Get map shard.
	shard := c.GetShard(p.Metric)
	shard.Lock()
	defer shard.Unlock()

	values, exists := shard.items[p.Metric]
	if c.throttle != nil && c.throttle(p, exists) {
		return
	}

	count := len(p.Data)
	if s.maxSize > 0 && c.Size() > s.maxSize {
		atomic.AddUint32(&c.stat.overflowCnt, uint32(count))
		return
	}

	if exists {
		values.Data = append(values.Data, p.Data...)
	} else {
		shard.items[p.Metric] = p

		if shard.adds != nil {
			shard.adds[p.Metric] = struct{}{}
		}
		// if no bloom filter - just add metric to new channel
		// if missed in cache, as it was before
		if c.newMetricsChan != nil && c.newMetricCf == nil {
			sendMetricToNewMetricChan(c, p.Metric)
		}
	}

	// if we have both new metric channel and bloom filter
	if c.newMetricsChan != nil && c.newMetricCf != nil {
		// add metric to new metric channel if missed in bloom
		// despite what we have it in cache (new behaviour)
		if !c.newMetricCf.Has(xxhash.Sum64([]byte(p.Metric))) {
			sendMetricToNewMetricChan(c, p.Metric)
			c.newMetricCf.Add(xxhash.Sum64([]byte(p.Metric)))
		}

	}
	atomic.AddInt32(&c.stat.size, int32(count))
}

// Pop removes an element from the map and returns it
func (c *Cache) Pop(key string) (p *points.Points, exists bool) {
	// Try to get shard.
	shard := c.GetShard(key)
	shard.Lock()
	p, exists = shard.items[key]
	delete(shard.items, key)
	shard.Unlock()

	if exists {
		atomic.AddInt32(&c.stat.size, -int32(len(p.Data)))
	}

	return p, exists
}

func (c *Cache) PopNotConfirmed(key string) (p *points.Points, exists bool) {
	// Try to get shard.
	shard := c.GetShard(key)
	shard.Lock()
	p, exists = shard.items[key]
	delete(shard.items, key)

	if exists {
		if shard.notConfirmedUsed < len(shard.notConfirmed) {
			shard.notConfirmed[shard.notConfirmedUsed] = p
		} else {
			shard.notConfirmed = append(shard.notConfirmed, p)
		}
		shard.notConfirmedUsed++
	}
	shard.Unlock()

	if exists {
		atomic.AddInt32(&c.stat.size, -int32(len(p.Data)))
	}

	return p, exists
}

func (c *Cache) WriteoutQueue() *WriteoutQueue {
	return c.writeoutQueue
}

// called at every scan-frequency by fileListUpdater in carbonserver.
// Iterates over every shard to:
// - collect the new metric names (append shard.adds map to slice)
// - replace shard.adds with new empty map
func (c *Cache) GetRecentNewMetrics() []map[string]struct{} {
	metricNames := make([]map[string]struct{}, shardCount)
	for i := 0; i < shardCount; i++ {
		shard, newAdds := c.data[i], make(map[string]struct{})
		shard.Lock()
		currNames := shard.adds
		shard.adds = newAdds
		shard.Unlock()
		metricNames[i] = currNames
	}
	return metricNames
}

func (c *Cache) SetThrottle(throttle func(ps *points.Points, inCache bool) bool) {
	c.throttle = throttle
}

func (c *Cache) GetInfo() map[string]interface{} {
	s, ok := c.settings.Load().(*cacheSettings)
	if !ok {
		return map[string]interface{}{}
	}

	return map[string]interface{}{
		"size":  c.stat.size,
		"limit": s.maxSize,
	}
}
