package session

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"sync"
	"time"

	"github.com/spf13/viper"

	lru "github.com/hashicorp/golang-lru/v2/expirable"

	"github.com/tidbcloud/account/commons/metrics"

	rawredis "github.com/go-redis/redis/v8"
	"github.com/tidbcloud/account/commons/redis"
	"github.com/tidbcloud/tiinf/errs"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"
)

var (
	localSessionCacheOnce sync.Once
	localSessionCache     *lru.LRU[string, *cacheSessionVal]

	localSessionCacheDefaultTTL      = 5 * time.Minute
	localSessionCacheDefaultCapacity = 16384

	cacheSessionKey = "account:user:sid:%s"
	cacheUserKey    = "account:user:email:%s"

	loadUserMetaLockKey = "account:lock:load_user_meta:%s"

	sessionInvalidationChannel = "DeleteSessionCache"

	redisErrMsg = "get key from redis error"
)

type cacheSessionVal struct {
	RawToken   string `json:"raw_token"`
	SessionID  string `json:"sid"`
	Email      string `json:"email"`
	Usage      string `json:"usage"`
	TokenExp   int64  `json:"token_exp"`
	SessionExp int64  `json:"sess_exp"`
}

type cacheUserVal struct {
	ID          uint64 `json:"id"`
	Email       string `json:"email"`
	FirstName   string `json:"first_name"`
	LastName    string `json:"last_name"`
	Status      string `json:"status"`
	Job         string `json:"job"`
	PhoneNumber string `json:"phone_number"`
	CreatedAt   int64  `json:"created_at"`
}

func getSessionLocalCache() *lru.LRU[string, *cacheSessionVal] {
	localSessionCacheOnce.Do(func() {
		ttl := viper.GetDuration("cache.session.local.ttl")
		if ttl <= 0 {
			ttl = localSessionCacheDefaultTTL
		}
		capacity := viper.GetInt("cache.session.local.capacity")
		if capacity <= 0 {
			capacity = localSessionCacheDefaultCapacity
		}
		var onEvict lru.EvictCallback[string, *cacheSessionVal]
		localSessionCache = lru.NewLRU[string, *cacheSessionVal](capacity, onEvict, ttl)
		tilog.Info(context.Background(), "init local session lru cache",
			zap.Int("capacity", capacity), zap.Any("ttl", ttl))
	})
	return localSessionCache
}

func setCacheSession(ctx context.Context, sVal *cacheSessionVal, exp time.Duration) error {
	getSessionLocalCache().Add(sVal.SessionID, sVal)

	key := fmt.Sprintf(cacheSessionKey, sVal.SessionID)
	marshal, _ := json.Marshal(sVal)
	val := string(marshal)
	return redis.GetClient().SetEX(ctx, key, val, exp).Err()
}

func deleteCacheSession(ctx context.Context, sids ...string) error {
	if len(sids) == 0 {
		return nil
	}

	var ks []string
	for _, sid := range sids {
		k := fmt.Sprintf(cacheSessionKey, sid)
		ks = append(ks, k)
	}
	if err := redis.GetClient().Del(ctx, ks...).Err(); err != nil {
		return err
	}

	for _, sid := range sids {
		getSessionLocalCache().Remove(sid)
	}

	publishSessionInvalidation(ctx, sids)
	return nil
}

func publishSessionInvalidation(ctx context.Context, sids []string) {
	payload, err := json.Marshal(sids)
	if err != nil {
		tilog.Warn(ctx, "publishSessionInvalidation marshal error", zap.Any("sids", sids), zap.Any("err", err))
		return
	}
	if err := redis.GetClient().Publish(ctx, sessionInvalidationChannel, string(payload)).Err(); err != nil {
		tilog.Warn(ctx, "publishSessionInvalidation publish error", zap.Any("sids", sids), zap.Any("err", err))
	}
}

func SubscribeSessionInvalidation(ctx context.Context) {
	tilog.Info(ctx, "Session cache invalidation subscribe start")
	pubsub := redis.GetClient().Subscribe(ctx, sessionInvalidationChannel)
	hostname, _ := os.Hostname()
	ch := pubsub.Channel()
	for msg := range ch {
		var sids []string
		if err := json.Unmarshal([]byte(msg.Payload), &sids); err != nil {
			tilog.Warn(ctx, "Session cache invalidation unmarshal error",
				zap.Any("payload", msg.Payload), zap.Any("hostname", hostname), zap.Any("err", err))
			continue
		}
		for _, sid := range sids {
			getSessionLocalCache().Remove(sid)
		}
		tilog.Info(ctx, "Session cache invalidation delete local cache",
			zap.Any("sids", sids), zap.Any("hostname", hostname))
	}
}

func getCacheSession(ctx context.Context, sid string) (*cacheSessionVal, error) {
	if val, ok := getSessionLocalCache().Get(sid); ok {
		tilog.Info(ctx, "getCacheSession hit local cache", zap.Any("sid", sid))
		return val, nil
	}

	start := time.Now()
	defer func() {
		metrics.RecAuthnSessionMetrics("AuthnLoginSession", "redis_query", start, true)
	}()

	key := fmt.Sprintf(cacheSessionKey, sid)
	val, err := redis.GetClient().Get(ctx, key).Result()
	if err != nil {
		if err == rawredis.Nil {
			tilog.Info(ctx, "getCacheSession not found in redis", zap.Any("sid", sid))
			return nil, nil
		}
		tilog.Warn(ctx, "getCacheSession redis error", zap.Any("sid", sid), zap.Any("err", err))
		return nil, errs.NewInternalRedis(redisErrMsg, err)
	}

	if val == "" {
		tilog.Info(ctx, "getCacheSession empty value in redis", zap.Any("sid", sid))
		return nil, nil
	}

	tilog.Info(ctx, "getCacheSession hit redis cache", zap.Any("sid", sid))

	var sVal cacheSessionVal
	err = json.Unmarshal([]byte(val), &sVal)
	if err != nil {
		tilog.Error(ctx, "getCacheSession unmarshal error", zap.Any("sid", sid), zap.Any("err", err))
		return nil, err
	}

	getSessionLocalCache().Add(sid, &sVal)

	return &sVal, nil
}

func isRedisError(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, errs.ErrInternalRedis) {
		return true
	}
	return err.Error() == redisErrMsg
}

func setCacheUser(ctx context.Context, uVal *cacheUserVal, exp time.Duration) error {
	key := fmt.Sprintf(cacheUserKey, uVal.Email)
	marshal, _ := json.Marshal(uVal)
	val := string(marshal)
	return redis.GetClient().SetEX(ctx, key, val, exp).Err()
}

func getCacheUser(ctx context.Context, email string) (*cacheUserVal, error) {
	key := fmt.Sprintf(cacheUserKey, email)
	val, err := redis.GetClient().Get(ctx, key).Result()
	if err != nil {
		if err == rawredis.Nil {
			val = ""
		} else {
			return nil, errs.NewInternalRedis("get key from redis error", err)
		}
	}
	if val == "" {
		return nil, nil
	}
	var uVal cacheUserVal
	err = json.Unmarshal([]byte(val), &uVal)
	if err != nil {
		return nil, err
	}
	return &uVal, nil
}

func setLoadUserLock(ctx context.Context, email string, exp time.Duration) (bool, error) {
	key := fmt.Sprintf(loadUserMetaLockKey, email)
	return redis.GetClient().SetNX(ctx, key, "1", exp).Result()
}

func delLoadUserLock(ctx context.Context, email string) error {
	key := fmt.Sprintf(loadUserMetaLockKey, email)
	return redis.GetClient().Del(ctx, key).Err()
}
