package redistool

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/rueidis"
	"google.golang.org/protobuf/proto"
)

type BuilderKV[K2 any] struct {
	HashKey K2
	// Value is the value to store in Redis.
	Value *ExpiringValue
}

// IOBuilder allows to batch Set and Unset commands.
// Can be reused after the Do method is called.
type IOBuilder[K1 any, K2 any] interface {
	// Set enqueues a HSET command for each key in keys. Does nothing if no keys or kvs provided.
	Set(keys []K1, ttl time.Duration, kvs ...BuilderKV[K2])
	// Unset enqueues a HDEL command for each key. Does nothing if no keys or no hashKeys provided.
	Unset(keys []K1, hashKeys ...K2)
	// Do executes enqueued commands. Does nothing if no commands have been enqueued.
	Do(context.Context) error
}

type redisIOBuilder[K1 any, K2 any] struct {
	client         rueidis.Client
	key1ToRedisKey KeyToRedisKey[K1]
	key2ToRedisKey KeyToRedisKey[K2]
	cmds           []rueidis.Completed
	err            error
}

func (b *redisIOBuilder[K1, K2]) Set(keys []K1, ttl time.Duration, kvs ...BuilderKV[K2]) {
	if len(keys) == 0 || len(kvs) == 0 || b.err != nil {
		return
	}

	kvs2fvs := make([]fv, 0, len(kvs))
	for _, kv := range kvs {
		redisValue, err := proto.Marshal(kv.Value)
		if err != nil {
			// This should never happen. Rather than returning an error here and complicating the API, we store the error
			// and return it when Do() is called.
			b.err = fmt.Errorf("failed to marshal ExpiringValue: %w", err)
			return
		}
		kvs2fvs = append(kvs2fvs, fv{
			field: b.key2ToRedisKey(kv.HashKey),
			value: rueidis.BinaryString(redisValue),
		})
	}
	for _, key := range keys {
		redisKey := b.key1ToRedisKey(key)
		hsetCmd := b.client.B().Hset().Key(redisKey).FieldValue()
		for _, kv := range kvs2fvs {
			hsetCmd.FieldValue(kv.field, kv.value)
		}
		b.cmds = append(b.cmds,
			hsetCmd.Build(),
			b.client.B().Pexpire().Key(redisKey).Milliseconds(ttl.Milliseconds()).Build(),
		)
	}
}

func (b *redisIOBuilder[K1, K2]) Unset(keys []K1, hashKeys ...K2) {
	if b.err != nil {
		return
	}

	fields := make([]string, 0, len(hashKeys))
	for _, hk := range hashKeys {
		fields = append(fields, b.key2ToRedisKey(hk))
	}
	for _, key := range keys {
		cmd := b.client.B().Hdel().Key(b.key1ToRedisKey(key)).Field(fields...).Build()
		b.cmds = append(b.cmds, cmd)
	}
}

func (b *redisIOBuilder[K1, K2]) Do(ctx context.Context) error {
	defer func() { // reset builder state to allow reuse
		clear(b.cmds)       // clear the backing array to allow GC
		b.cmds = b.cmds[:0] // reset length to 0 but reuse backing array
		b.err = nil
	}()
	if b.err != nil { // must be checked before the b.cmds length.
		return b.err
	}
	switch len(b.cmds) {
	case 0:
		return nil
	case 1:
		return b.client.Do(ctx, b.cmds[0]).Error()
	default:
		multi := make([]rueidis.Completed, 0, len(b.cmds)+2)
		multi = append(multi, b.client.B().Multi().Build())
		multi = append(multi, b.cmds...)
		multi = append(multi, b.client.B().Exec().Build())

		resp := b.client.DoMulti(ctx, multi...)

		return errors.Join(MultiErrors(resp)...)
	}
}

type fv struct {
	field string
	value string
}
