package rediczar

import (
	"context"
	"fmt"
	"strconv"
	"time"

	"github.com/go-redis/redis/v7"
)

// IncrWithTTL increments and updates the expiry on the key, regardless of any existing expiry value. The
// new value of the key is returned
func (c *Client) IncrWithTTL(ctx context.Context, key string, ttl time.Duration) (int64, error) {
	const script = `
local c = tonumber(redis.call("incr", KEYS[1]))
redis.call("pexpire", KEYS[1], ARGV[1])
return c`

	t := strconv.FormatInt(int64(ttl/time.Millisecond), 10)

	out, err := c.Eval(ctx, script, []string{key}, t)
	if err != nil {
		return 0, err
	}

	i, ok := out.(int64)
	if !ok {
		// This should never happen
		return 0, fmt.Errorf("IncrWithTTL: internal error converting output value: %v", out)
	}

	return i, nil
}

// RateIncr increments and sets an expiry on the key if it was 0. The new value of the key is returned. This
// can be used to rate limit over a non-overlapping interval
func (c *Client) RateIncr(ctx context.Context, key string, ttl time.Duration) (int64, error) {
	const script = `
local c = tonumber(redis.call("incr", KEYS[1]))
if c == 1 then redis.call("pexpire", KEYS[1], ARGV[1]) end
return c`

	t := strconv.FormatInt(int64(ttl/time.Millisecond), 10)

	out, err := c.Eval(ctx, script, []string{key}, t)
	if err != nil {
		return 0, err
	}

	i, ok := out.(int64)
	if !ok {
		// This should never happen
		return 0, fmt.Errorf("RateIncr: internal error converting output value=%v", out)
	}

	return i, nil
}

// MRateIncr leverages redis pipelining to issue many RateIncr commands. The new value of the corresponding key is returned
func (c *Client) MRateIncr(ctx context.Context, keys []string, ttl time.Duration) ([]int64, error) {
	const script = `
local c = tonumber(redis.call("incr", KEYS[1]))
if c == 1 then redis.call("pexpire", KEYS[1], ARGV[1]) end
return c`
	t := strconv.FormatInt(int64(ttl/time.Millisecond), 10)

	cmds := make([]*redis.Cmd, len(keys))

	_, err := c.Pipelined(ctx, func(p redis.Pipeliner) error {
		for i, key := range keys {
			cmds[i] = p.Eval(script, []string{key}, t)
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	res := make([]int64, len(keys))

	for i, cmd := range cmds {
		out, ok := cmd.Val().(int64)
		if !ok {
			// This should never happen
			return nil, fmt.Errorf("MRateIncr: internal error converting output value=%v", cmd.Val())
		}
		res[i] = out
	}

	return res, nil
}

// SlidingWindowRateLimit returns whether the operation should be allowed. It will not allow more
// than `maxElements` within the `ttl` duration. This can be used to rate limit over an overlapping interval
func (c *Client) SlidingWindowRateLimit(ctx context.Context, key string, ttl time.Duration, maxElements int64) (bool, error) {
	return c.slidingWindowRateLimit(ctx, key, ttl, maxElements, false)
}

// QuerySlidingWindow returns whether the operation should be allowed. It will not allow more
// than `maxElements` within the `ttl` duration. This method is used to query the current
// rate limiting state, and will *not* insert a new operation.
func (c *Client) QuerySlidingWindow(ctx context.Context, key string, ttl time.Duration, maxElements int64) (bool, error) {
	return c.slidingWindowRateLimit(ctx, key, ttl, maxElements, true)
}

func (c *Client) slidingWindowRateLimit(ctx context.Context, key string, ttl time.Duration, maxElements int64, readOnly bool) (bool, error) {
	const script = `
-- Key we are updating
local key=KEYS[1]
-- ttl for the Key so it cleans itself up
local ttl=ARGV[1]
-- score for current time, so we can delete expired entries
local nowTime=ARGV[2]
-- score for the new element
local expirationTime=ARGV[3]
-- max number of elements allowed in this window
local maxElements=tonumber(ARGV[4])
-- if true, will return whether there is space in the current window without adding a new item
local readOnly=tonumber(ARGV[5])
-- delete all entries that have expired
redis.call('ZREMRANGEBYSCORE', key, '-inf', nowTime)
local card = tonumber(redis.call('ZCARD', key))
-- If there is space in the window
if maxElements >= card+1 then
	if readOnly == 0 then
		-- Add the entry, use the score as the name
		redis.call('ZADD', key, expirationTime, expirationTime)
		redis.call('EXPIRE', key, ttl)
	end
	return 1
end
return 0
`

	now := time.Now()

	t := int(ttl.Seconds())
	nowTime := now.UnixNano() / int64(time.Microsecond)
	expirationTime := now.Add(ttl).UnixNano() / int64(time.Microsecond)

	out, err := c.Eval(ctx, script, []string{key}, t, nowTime, expirationTime, maxElements, readOnly)
	if err != nil {
		return false, err
	}

	i, ok := out.(int64)
	if !ok {
		// This should never happen
		return false, fmt.Errorf("SlidingWindowRateLimit: internal error converting output value=%v", out)
	}
	return i > 0, nil
}