package rediczar

import (
	"context"
	"encoding/json"
	"errors"
	"reflect"
	"time"

	"code.justin.tv/chat/rediczar/internal/safereflect"
	"github.com/go-redis/redis/v7"
)

// Cached can cache any arbitrary type that encodes. Use other Cached* methods to cache common data types (like primitives)
// The type of `res` must be a pointer to the return type of `fn` (datasource function). This pattern of caching is called cache-aside.
// If the datasource returns nil, the value is cached (negative caching).
//
// Cached will make every attempt to succeed if there is a redis or serialization error. If the returned error is nil, then res is
// a value loaded from cache or the data source.
//
// A TTL of 0 means the value from the datasource should not be cached.
//
// Type examples for `res` and `fn`. Usage will normally pass in the address of `res`
//
//  * `res` is type *int, `fn` returns 10
//  * `res` is type *Foo, `fn` returns Foo
//  * `res` is type **Foo, `fn` returns nil or *Foo
//
// Usage example:
//
//	var u user
//	err := cache.Cached(ctx, "user:123", 10*time.Minute, &u, func() (interface{}, error) {
//		return user{ID: "123", Login: "kappa"}, nil // This would be retrieved from a service call
//	})
func (c *Client) Cached(ctx context.Context, key string, ttl time.Duration, res interface{}, fn func() (interface{}, error)) error {
	return c.cached(ctx, cacheOpts{
		key: key,
		res: res,
		datasource: staticDatasource{
			fetch: fn,
			ttl:   ttl,
		},
	})
}

type datasource interface {
	// Fetch is only called once to load the data
	Fetch() (interface{}, error)
	// TTL is how long the fetched data should be stored in the cache. This is always called
	// after Fetch is called.
	TTL() time.Duration
}

type staticDatasource struct {
	fetch func() (interface{}, error)
	ttl   time.Duration
}

func (s staticDatasource) Fetch() (interface{}, error) {
	return s.fetch()
}

func (s staticDatasource) TTL() time.Duration {
	return s.ttl
}

type cacheOpts struct {
	// key is the cache key
	key string
	// res is a pointer that the loaded value will be assigned to
	res interface{}
	// datasource fetches data and provides the TTL
	datasource datasource
	// if true, then encoding and decoding are skipped. res is and fetcher should both be string types
	skipSerialization bool
}

func (c *Client) cached(ctx context.Context, opts cacheOpts) error {
	v := reflect.ValueOf(opts.res)
	if v.Kind() != reflect.Ptr {
		return errors.New("cannot cache onto non-pointer")
	}

	// Use reflection to set `res` argument.
	rv := v.Elem()
	if !rv.CanSet() {
		return errors.New("cannot set onto a non-addressable result")
	}

	// Check key for cached value.
	resp, err := c.Get(ctx, opts.key)
	if err != nil {
		if err != redis.Nil {
			// log non-cache miss errors
			c.log("err", err, "key", opts.key, "getting cached value")
		}
		// continue on cache miss
	} else {
		if opts.skipSerialization {
			if err := safereflect.Set(resp, rv); err != nil {
				// return the error here because this is the string case, and setting onto
				// rv won't work later. this shouldn't happen because resp is a byte slice, and
				// this code path is only hit from CachedString
				return err
			}

			return nil
		}

		err := c.decode([]byte(resp), opts.res)
		if err == nil {
			return nil
		}

		// log deserialization errors. Refetching from the data source should forward correct the cache
		c.log("err", err, "key", opts.key, "decoding cached value")
	}

	// Fetch value from true data source
	fetched, err := opts.datasource.Fetch()
	if err != nil {
		return err
	}

	err = safereflect.Set(fetched, rv)
	if err != nil {
		return err
	}

	ttl := opts.datasource.TTL()

	// No TTL means do not cache
	if ttl <= 0 {
		return nil
	}

	// Cache value for future lookups. Don't short-circuit if there is an encoding or redis error
	var encodeErr error
	var encodeVal interface{}
	if opts.skipSerialization {
		encodeVal = fetched
	} else {
		encodeVal, encodeErr = c.encode(fetched)
	}

	if encodeErr != nil {
		c.log("err", encodeErr, "key", opts.key, "encoding fetched value")
	} else {
		_, setErr := c.SetNX(ctx, opts.key, encodeVal, ttl)
		if setErr != nil {
			c.log("err", setErr, "key", opts.key, "setting fetched value")
		}
	}

	return nil
}

// CachedBool is a type-safe convenience function to caches booleans
func (c *Client) CachedBool(ctx context.Context, key string, ttl time.Duration, fn func() (bool, error)) (bool, error) {
	var res bool
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedBoolTTLs stores durations for use in CachedBoolDifferentTTLs below.
type CachedBoolTTLs struct {
	TrueTTL  time.Duration
	FalseTTL time.Duration
}

// CachedBoolDifferentTTLs works like CachedBool but lets the caller specify a different cache TTL for true or false values
func (c *Client) CachedBoolDifferentTTLs(ctx context.Context, key string, ttls CachedBoolTTLs, fn func() (bool, error)) (bool, error) {
	var res bool
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: &diffBoolDatasource{
			fetch:    fn,
			trueTTL:  ttls.TrueTTL,
			falseTTL: ttls.FalseTTL,
		},
	})
	return res, err
}

// diffBoolDatasource returns a different TTL depending on the fetched result
type diffBoolDatasource struct {
	fetch    func() (bool, error)
	result   bool
	trueTTL  time.Duration
	falseTTL time.Duration
}

func (d *diffBoolDatasource) Fetch() (interface{}, error) {
	res, err := d.fetch()
	d.result = res
	return res, err
}

func (d *diffBoolDatasource) TTL() time.Duration {
	if d.result {
		return d.trueTTL
	}

	return d.falseTTL
}

// CachedInt is a type-safe convenience function to cache ints
func (c *Client) CachedInt(ctx context.Context, key string, ttl time.Duration, fn func() (int, error)) (int, error) {
	var res int
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedInt64 is a type-safe convenience function to cache int64s
func (c *Client) CachedInt64(ctx context.Context, key string, ttl time.Duration, fn func() (int64, error)) (int64, error) {
	var res int64
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedString is a type-safe convenience function to cache strings and is optimized to skip encoding. It's highly recommended
// to use this over Cached when caching string values
func (c *Client) CachedString(ctx context.Context, key string, ttl time.Duration, fn func() (string, error)) (string, error) {
	var res string
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
		skipSerialization: true, // skip serializations for strings because it's unnecessary
	})
	return res, err
}

// CachedIntSlice is a type-safe convenience function to cache int slices
func (c *Client) CachedIntSlice(ctx context.Context, key string, ttl time.Duration, fn func() ([]int, error)) ([]int, error) {
	var res []int
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedInt64Slice is a type-safe convenience function to cache int64 slices
func (c *Client) CachedInt64Slice(ctx context.Context, key string, ttl time.Duration, fn func() ([]int64, error)) ([]int64, error) {
	var res []int64
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedStringSlice is a type-safe convenience function to cache string slices
func (c *Client) CachedStringSlice(ctx context.Context, key string, ttl time.Duration, fn func() ([]string, error)) ([]string, error) {
	var res []string
	err := c.cached(ctx, cacheOpts{
		key: key,
		res: &res,
		datasource: staticDatasource{
			fetch: func() (interface{}, error) {
				return fn()
			},
			ttl: ttl,
		},
	})
	return res, err
}

// CachedGet gets the key and deserializes into res. The type of `res` must be a pointer.
// If the cache value exists then nil is returned, otherwise redis.Nil is returned
func (c *Client) CachedGet(ctx context.Context, key string, res interface{}) error {
	v := reflect.ValueOf(res)
	if v.Kind() != reflect.Ptr {
		return errors.New("cannot cache onto non-pointer")
	}

	// Use reflection to set `res` argument.
	rv := v.Elem()
	if !rv.CanSet() {
		return errors.New("cannot set onto a non-addressable result")
	}

	resp, err := c.Get(ctx, key)
	if err != nil {
		return err
	}

	return c.decode([]byte(resp), res)
}

// CachedSet serializes obj and sets the key with the TTL. This forces the cache to update
func (c *Client) CachedSet(ctx context.Context, key string, ttl time.Duration, obj interface{}) error {
	val, err := c.encode(obj)
	if err != nil {
		return err
	}
	_, err = c.Set(ctx, key, val, ttl)
	return err
}

func (c *Client) encode(v interface{}) ([]byte, error) {
	if c.Encode == nil {
		return json.Marshal(v)
	}

	return c.Encode(v)
}

func (c *Client) decode(b []byte, v interface{}) error {
	if c.Decode == nil {
		return json.Unmarshal(b, v)
	}

	return c.Decode(b, v)
}
