package repository

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	libmongo "apps/backend/internal/lib/mongo"
	"apps/backend/internal/lib/secretbox"
	"apps/backend/internal/module/member/domain"

	"github.com/zeromicro/go-zero/core/logx"
	"github.com/zeromicro/go-zero/core/stores/cache"
	"github.com/zeromicro/go-zero/core/stores/mon"
	"github.com/zeromicro/go-zero/core/stores/monc"
	"github.com/zeromicro/go-zero/core/stores/redis"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"golang.org/x/crypto/bcrypt"
)

const (
	colMembers    = "members"
	colIdentities = "identities"
	colSettings   = "member_settings"
	colCounters   = "counters"
	colRefresh    = "refresh_tokens"
	colAuthCodes  = "auth_codes"

	settingsCacheVersion     = 1
	settingsCacheTTL         = 30 * time.Minute
	settingsNegativeCacheTTL = time.Minute
	settingsCacheTimeout     = 750 * time.Millisecond
	settingsMongoReadTimeout = 5 * time.Second
)

// MoncStore: members/identities via monc; settings use explicit ciphertext Redis caching.
type MoncStore struct {
	uri                    string
	db                     string
	member                 *monc.Model
	plain                  *mon.Model
	identity               *monc.Model
	idPlain                *mon.Model
	settings               *mon.Model
	settingsCache          settingsCache
	settingsCacheNamespace string
	refresh                *mon.Model
	codes                  *mon.Model
	secrets                *secretbox.Codec
}

func NewMoncStore(uri, database string, cacheConf cache.CacheConf, settingsRedisConf redis.RedisConf, redisNamespace string, secrets *secretbox.Codec) *MoncStore {
	uri = libmongo.MustMongoURI(uri)
	return &MoncStore{
		uri:                    uri,
		db:                     database,
		member:                 monc.MustNewModel(uri, database, colMembers, cacheConf),
		plain:                  mon.MustNewModel(uri, database, colMembers),
		identity:               monc.MustNewModel(uri, database, colIdentities, cacheConf),
		idPlain:                mon.MustNewModel(uri, database, colIdentities),
		settings:               mon.MustNewModel(uri, database, colSettings),
		settingsCache:          &redisSettingsCache{client: redis.MustNewRedis(settingsRedisConf)},
		settingsCacheNamespace: normalizeSettingsCacheNamespace(redisNamespace),
		refresh:                mon.MustNewModel(uri, database, colRefresh),
		codes:                  mon.MustNewModel(uri, database, colAuthCodes),
		secrets:                secrets,
	}
}

func cacheKeyUID(uid int64) string { return fmt.Sprintf("cache:member:uid:%d", uid) }
func cacheKeyEmail(email string) string {
	return fmt.Sprintf("cache:member:email:%s", strings.ToLower(email))
}
func cacheKeyPhone(phone string) string {
	return fmt.Sprintf("cache:member:phone:%s", strings.TrimSpace(phone))
}
func cacheKeyIdentity(loginID, platform string) string {
	return fmt.Sprintf("cache:identity:%s:%s", platform, strings.ToLower(loginID))
}
func cacheKeyIdentitiesUID(uid int64) string {
	return fmt.Sprintf("cache:identities:uid:%d", uid)
}

func (s *MoncStore) NextUID(ctx context.Context) (int64, error) {
	counters := mon.MustNewModel(s.uri, s.db, colCounters)
	filter := bson.M{"_id": "member_uid"}
	update := bson.M{"$inc": bson.M{"seq": int64(1)}}
	opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
	var doc struct {
		Seq int64 `bson:"seq"`
	}
	err := counters.FindOneAndUpdate(ctx, &doc, filter, update, opts)
	if err != nil {
		return 0, err
	}
	// Floor at MinMemberUID (1_000_000 → 8 digits). First free uid after seed admin.
	if doc.Seq < domain.MinMemberUID {
		// Jump counter to MinMemberUID; return next free after reserved seed band.
		// Seed admin uses MinMemberUID; auto-issue starts at MinMemberUID+1.
		next := domain.MinMemberUID + 1
		_, _ = counters.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"seq": next}})
		return next, nil
	}
	return doc.Seq, nil
}

func (s *MoncStore) CreateMember(ctx context.Context, m *domain.Member) error {
	m.Email = strings.ToLower(strings.TrimSpace(m.Email))
	m.Phone = strings.TrimSpace(m.Phone)
	_, err := s.member.InsertOne(ctx, cacheKeyUID(m.UID), m)
	if err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return domain.ErrEmailTaken
		}
		return err
	}
	_ = s.member.DelCache(ctx, cacheKeyEmail(m.Email))
	if m.Phone != "" {
		_ = s.member.DelCache(ctx, cacheKeyPhone(m.Phone))
	}
	return nil
}

func (s *MoncStore) FindByUID(ctx context.Context, uid int64) (*domain.Member, error) {
	var m domain.Member
	err := s.member.FindOne(ctx, cacheKeyUID(uid), &m, bson.M{"uid": uid})
	if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	return &m, nil
}

func (s *MoncStore) FindByEmail(ctx context.Context, email string) (*domain.Member, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	var m domain.Member
	err := s.member.FindOne(ctx, cacheKeyEmail(email), &m, bson.M{"email": email})
	if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	return &m, nil
}

func (s *MoncStore) FindByPhone(ctx context.Context, phone string) (*domain.Member, error) {
	phone = strings.TrimSpace(phone)
	if phone == "" {
		return nil, domain.ErrNotFound
	}
	var m domain.Member
	err := s.member.FindOne(ctx, cacheKeyPhone(phone), &m, bson.M{"phone": phone})
	if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	return &m, nil
}

func (s *MoncStore) UpdateMember(ctx context.Context, m *domain.Member) error {
	m.Email = strings.ToLower(strings.TrimSpace(m.Email))
	m.Phone = strings.TrimSpace(m.Phone)
	m.UpdatedAt = time.Now().UTC().UnixNano()
	filter := bson.M{"uid": m.UID}
	res, err := s.plain.ReplaceOne(ctx, filter, m)
	if err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return domain.ErrEmailTaken
		}
		return err
	}
	if res.MatchedCount == 0 {
		return domain.ErrNotFound
	}
	_ = s.member.DelCache(ctx, cacheKeyUID(m.UID), cacheKeyEmail(m.Email))
	if m.Phone != "" {
		_ = s.member.DelCache(ctx, cacheKeyPhone(m.Phone))
	}
	return nil
}

func (s *MoncStore) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 50 {
		pageSize = 50
	}
	// Harbor members use numeric int64 uid. Exclude legacy docs (e.g. UUID string uid
	// from older systems in the same Mongo DB) so decode cannot blow up admin list.
	filter := bson.M{
		"uid": bson.M{"$type": bson.A{"int", "long", "double"}},
	}
	q := strings.TrimSpace(query)
	if q != "" {
		filter["$or"] = []bson.M{
			{"email": bson.M{"$regex": q, "$options": "i"}},
			{"display_name": bson.M{"$regex": q, "$options": "i"}},
			{"phone": bson.M{"$regex": q, "$options": "i"}},
			{"nickname": bson.M{"$regex": q, "$options": "i"}},
		}
	}
	if st := strings.TrimSpace(status); st != "" {
		filter["status"] = st
	}
	total, err := s.plain.CountDocuments(ctx, filter)
	if err != nil {
		return nil, 0, err
	}
	var list []*domain.Member
	err = s.plain.Find(ctx, &list, filter,
		options.Find().SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "uid", Value: 1}}),
	)
	if err != nil {
		return nil, 0, err
	}
	return list, total, nil
}

func (s *MoncStore) CountActiveAdmins(ctx context.Context) (int64, error) {
	return s.plain.CountDocuments(ctx, bson.M{"status": domain.StatusActive, "roles": domain.RoleAdmin})
}

func (s *MoncStore) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) {
	code = strings.ToUpper(strings.TrimSpace(code))
	if code == "" {
		return nil, domain.ErrNotFound
	}
	var m domain.Member
	err := s.plain.FindOne(ctx, &m, bson.M{
		"invite_code": code,
		"uid":         bson.M{"$type": bson.A{"int", "long", "double"}},
	})
	if err != nil {
		if errors.Is(err, mon.ErrNotFound) || errors.Is(err, mongo.ErrNoDocuments) {
			return nil, domain.ErrNotFound
		}
		return nil, err
	}
	return &m, nil
}

func (s *MoncStore) ListAllMembers(ctx context.Context) ([]*domain.Member, error) {
	filter := bson.M{"uid": bson.M{"$type": bson.A{"int", "long", "double"}}}
	var list []*domain.Member
	err := s.plain.Find(ctx, &list, filter, options.Find().SetSort(bson.D{{Key: "uid", Value: 1}}))
	if err != nil {
		return nil, err
	}
	return list, nil
}

// --- identities ---

func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error {
	id.LoginID = normalizeLoginID(id.LoginID, id.Platform, id.AccountType)
	id.Platform = strings.ToLower(strings.TrimSpace(id.Platform))
	if id.CreatedAt == 0 {
		id.CreatedAt = domain.NowNano()
	}
	id.UpdatedAt = id.CreatedAt
	key := cacheKeyIdentity(id.LoginID, id.Platform)
	_, err := s.identity.InsertOne(ctx, key, id)
	if err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return domain.ErrIdentityTaken
		}
		return err
	}
	_ = s.identity.DelCache(ctx, cacheKeyIdentitiesUID(id.UID))
	return nil
}

func (s *MoncStore) FindIdentity(ctx context.Context, loginID, platform string) (*domain.Identity, error) {
	platform = strings.ToLower(strings.TrimSpace(platform))
	loginID = normalizeLoginID(loginID, platform, "")
	var id domain.Identity
	err := s.identity.FindOne(ctx, cacheKeyIdentity(loginID, platform), &id, bson.M{
		"login_id": loginID, "platform": platform,
	})
	if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) {
		return nil, domain.ErrIdentityNotFound
	}
	if err != nil {
		return nil, err
	}
	return &id, nil
}

func (s *MoncStore) ListIdentitiesByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) {
	var list []*domain.Identity
	// list is multi-doc; use plain mon (no monc multi-get)
	err := s.idPlain.Find(ctx, &list, bson.M{"uid": uid}, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
	if err != nil {
		return nil, err
	}
	return list, nil
}

func (s *MoncStore) DeleteIdentity(ctx context.Context, loginID, platform string) error {
	platform = strings.ToLower(strings.TrimSpace(platform))
	loginID = normalizeLoginID(loginID, platform, "")
	var existing domain.Identity
	err := s.idPlain.FindOne(ctx, &existing, bson.M{"login_id": loginID, "platform": platform})
	if errors.Is(err, mon.ErrNotFound) {
		return domain.ErrIdentityNotFound
	}
	if err != nil {
		return err
	}
	_, err = s.idPlain.DeleteOne(ctx, bson.M{"login_id": loginID, "platform": platform})
	if err != nil {
		return err
	}
	_ = s.identity.DelCache(ctx, cacheKeyIdentity(loginID, platform), cacheKeyIdentitiesUID(existing.UID))
	return nil
}

func (s *MoncStore) UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error {
	platform = strings.ToLower(strings.TrimSpace(platform))
	loginID = normalizeLoginID(loginID, platform, "")
	res, err := s.idPlain.UpdateOne(ctx,
		bson.M{"login_id": loginID, "platform": platform},
		bson.M{"$set": bson.M{"password_hash": passwordHash, "updated_at": domain.NowNano()}},
	)
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return domain.ErrIdentityNotFound
	}
	_ = s.identity.DelCache(ctx, cacheKeyIdentity(loginID, platform))
	return nil
}

func normalizeLoginID(loginID, platform, accountType string) string {
	loginID = strings.TrimSpace(loginID)
	if platform == domain.PlatformPassword || accountType == domain.AccountTypeEmail {
		return strings.ToLower(loginID)
	}
	if strings.Contains(loginID, "@") {
		return strings.ToLower(loginID)
	}
	return loginID
}

// --- durable auth_codes ---

type authCodeDoc struct {
	ID        string    `bson:"_id"`
	Kind      string    `bson:"kind"`
	Key       string    `bson:"key"`
	Code      string    `bson:"code,omitempty"` // legacy plaintext reader
	CodeHash  string    `bson:"code_hash,omitempty"`
	Attempts  int       `bson:"attempts"`
	CreatedAt time.Time `bson:"created_at"`
	ExpiresAt time.Time `bson:"expires_at"`
}

func (s *MoncStore) SetCode(kind, key, code string, ttl time.Duration) bool {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.MinCost)
	if err != nil {
		return false
	}
	now := time.Now().UTC()
	id := kind + ":" + key
	filter := bson.M{"_id": id, "$or": bson.A{
		bson.M{"created_at": bson.M{"$lte": now.Add(-time.Minute)}},
		bson.M{"created_at": bson.M{"$exists": false}},
		bson.M{"expires_at": bson.M{"$lte": now}},
	}}
	update := bson.M{"$set": bson.M{
		"kind": kind, "key": key, "code_hash": string(hash), "attempts": 0,
		"created_at": now, "expires_at": now.Add(ttl),
	}, "$unset": bson.M{"code": ""}}
	var doc authCodeDoc
	err = s.codes.FindOneAndUpdate(ctx, &doc, filter, update, options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After))
	if err != nil {
		return false
	}
	return true
}

// CheckCode 比對驗證碼：
//   - 正確 → 刪除（一次性消費）並回 true
//   - 錯誤 → 不刪，TTL 內可重試
//   - 過期 → 刪除（清垃圾）並回 false
func (s *MoncStore) CheckCode(kind, key, code string) bool {
	return s.verifyCode(kind, key, code, true)
}

// PeekCode 只檢查、不消費；過期時順便刪除。
func (s *MoncStore) PeekCode(kind, key, code string) bool {
	return s.verifyCode(kind, key, code, false)
}

func (s *MoncStore) verifyCode(kind, key, code string, consume bool) bool {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	id := kind + ":" + key
	var doc authCodeDoc
	now := time.Now().UTC()
	err := s.codes.FindOneAndUpdate(ctx, &doc, bson.M{
		"_id": id, "expires_at": bson.M{"$gt": now}, "attempts": bson.M{"$lt": 5},
	}, bson.M{"$inc": bson.M{"attempts": 1}}, options.FindOneAndUpdate().SetReturnDocument(options.Before))
	if err != nil {
		return false
	}
	matched := false
	if doc.CodeHash != "" {
		matched = bcrypt.CompareHashAndPassword([]byte(doc.CodeHash), []byte(code)) == nil
	} else {
		matched = doc.Code == code
	}
	if !matched {
		return false
	}
	if consume {
		_, err = s.codes.DeleteOne(ctx, bson.M{"_id": id})
		return err == nil
	}
	return true
}

// --- durable refresh_tokens ---

type refreshDoc struct {
	JTI       string    `bson:"_id"`
	UID       int64     `bson:"uid"`
	ExpiresAt time.Time `bson:"expires_at"`
}

func (s *MoncStore) SaveRefresh(jti string, uid int64, exp time.Time) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	doc := refreshDoc{JTI: jti, UID: uid, ExpiresAt: exp.UTC()}
	_, _ = s.refresh.ReplaceOne(ctx, bson.M{"_id": jti}, doc, options.Replace().SetUpsert(true))
}

func (s *MoncStore) ConsumeRefresh(jti string) (int64, bool) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	var doc refreshDoc
	err := s.refresh.FindOneAndDelete(ctx, &doc, bson.M{"_id": jti})
	if err != nil {
		return 0, false
	}
	if time.Now().UTC().After(doc.ExpiresAt) {
		return 0, false
	}
	return doc.UID, true
}

func (s *MoncStore) RevokeRefresh(jti string) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_, _ = s.refresh.DeleteOne(ctx, bson.M{"_id": jti})
}

func (s *MoncStore) RevokeAllRefreshByUID(ctx context.Context, uid int64) error {
	_, err := s.refresh.DeleteMany(ctx, bson.M{"uid": uid})
	return err
}

// --- settings ---

type settingsCache interface {
	Get(context.Context, string) (string, error)
	Set(context.Context, string, string, time.Duration) error
	Delete(context.Context, string) error
}

type redisSettingsCache struct {
	client *redis.Redis
}

func (c *redisSettingsCache) Get(ctx context.Context, key string) (string, error) {
	return c.client.GetCtx(ctx, key)
}

func (c *redisSettingsCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
	return c.client.SetexCtx(ctx, key, value, durationSeconds(ttl))
}

func (c *redisSettingsCache) Delete(ctx context.Context, key string) error {
	_, err := c.client.DelCtx(ctx, key)
	return err
}

type settingsCacheDTO struct {
	Version                  int               `json:"version"`
	Found                    bool              `json:"found"`
	UID                      int64             `json:"uid"`
	Provider                 string            `json:"provider,omitempty"`
	Model                    string            `json:"model,omitempty"`
	APIKeyConfigured         bool              `json:"api_key_configured,omitempty"`
	ResearchAPIKeyConfigured bool              `json:"research_api_key_configured,omitempty"`
	APIKey                   string            `json:"api_key,omitempty"`
	ProviderAPIKeys          map[string]string `json:"provider_api_keys,omitempty"`
	ExaAPIKey                string            `json:"exa_api_key,omitempty"`
	WebSearchProvider        string            `json:"web_search_provider,omitempty"`
	ExpandStrategy           string            `json:"expand_strategy,omitempty"`
	ExaAPIKeyConfigured      bool              `json:"exa_api_key_configured,omitempty"`
	DevModeEnabled           bool              `json:"dev_mode_enabled,omitempty"`
	UpdatedAt                int64             `json:"updated_at,omitempty"`
}

func settingsCacheKey(namespace string, uid int64) string {
	return fmt.Sprintf("%s:member:settings:{%d}", normalizeSettingsCacheNamespace(namespace), uid)
}

func normalizeSettingsCacheNamespace(namespace string) string {
	if namespace = strings.Trim(strings.TrimSpace(namespace), ":"); namespace != "" {
		return namespace
	}
	return "haixun:dev:v1"
}

func newSettingsCacheDTO(st *domain.UserSettings) (settingsCacheDTO, error) {
	if st == nil {
		return settingsCacheDTO{}, errors.New("nil member settings cache value")
	}
	if err := validateCachedCiphertext(st); err != nil {
		return settingsCacheDTO{}, err
	}
	return settingsCacheDTO{
		Version: settingsCacheVersion, Found: true, UID: st.UID,
		Provider: st.Provider, Model: st.Model,
		APIKeyConfigured: st.APIKeyConfigured, ResearchAPIKeyConfigured: st.ResearchAPIKeyConfigured,
		APIKey: st.APIKey, ProviderAPIKeys: cloneStringMap(st.ProviderAPIKeys), ExaAPIKey: st.ExaAPIKey,
		WebSearchProvider: st.WebSearchProvider, ExpandStrategy: st.ExpandStrategy,
		ExaAPIKeyConfigured: st.ExaAPIKeyConfigured, DevModeEnabled: st.DevModeEnabled, UpdatedAt: st.UpdatedAt,
	}, nil
}

func (d settingsCacheDTO) settings() *domain.UserSettings {
	return &domain.UserSettings{
		UID: d.UID, Provider: d.Provider, Model: d.Model,
		APIKeyConfigured: d.APIKeyConfigured, ResearchAPIKeyConfigured: d.ResearchAPIKeyConfigured,
		APIKey: d.APIKey, ProviderAPIKeys: cloneStringMap(d.ProviderAPIKeys), ExaAPIKey: d.ExaAPIKey,
		WebSearchProvider: d.WebSearchProvider, ExpandStrategy: d.ExpandStrategy,
		ExaAPIKeyConfigured: d.ExaAPIKeyConfigured, DevModeEnabled: d.DevModeEnabled, UpdatedAt: d.UpdatedAt,
	}
}

func validateCachedCiphertext(st *domain.UserSettings) error {
	values := map[string]string{"api_key": st.APIKey, "exa_api_key": st.ExaAPIKey}
	for provider, value := range st.ProviderAPIKeys {
		values["provider_api_keys."+provider] = value
	}
	for field, value := range values {
		if value != "" && !secretbox.IsEncrypted(value) {
			return fmt.Errorf("member settings cache %s is not ciphertext", field)
		}
	}
	return nil
}

func encodeSettingsCache(dto settingsCacheDTO) (string, error) {
	raw, err := json.Marshal(dto)
	return string(raw), err
}

func decodeSettingsCache(raw string, uid int64) (settingsCacheDTO, error) {
	var dto settingsCacheDTO
	if err := json.Unmarshal([]byte(raw), &dto); err != nil {
		return settingsCacheDTO{}, err
	}
	if dto.Version != settingsCacheVersion || dto.UID != uid {
		return settingsCacheDTO{}, errors.New("invalid member settings cache version or uid")
	}
	if dto.Found {
		if err := validateCachedCiphertext(dto.settings()); err != nil {
			return settingsCacheDTO{}, err
		}
	}
	return dto, nil
}

func readSettingsCache(ctx context.Context, cache settingsCache, key string, uid int64, codec *secretbox.Codec) (*domain.UserSettings, bool, error) {
	raw, err := cache.Get(ctx, key)
	if err != nil || raw == "" {
		return nil, false, err
	}
	dto, err := decodeSettingsCache(raw, uid)
	if err != nil {
		_ = cache.Delete(ctx, key)
		return nil, false, err
	}
	if !dto.Found {
		return domain.DefaultSettings(uid), true, nil
	}
	st, err := decryptSettings(codec, uid, dto.settings())
	if err != nil {
		_ = cache.Delete(ctx, key)
		return nil, false, err
	}
	return st, true, nil
}

func (s *MoncStore) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) {
	key := settingsCacheKey(s.settingsCacheNamespace, uid)
	cacheCtx, cacheCancel := context.WithTimeout(ctx, settingsCacheTimeout)
	st, hit, cacheErr := readSettingsCache(cacheCtx, s.settingsCache, key, uid, s.secrets)
	cacheCancel()
	if hit {
		return st, nil
	}
	if cacheErr != nil {
		logx.Errorf("member settings cache read failed uid=%d: %v", uid, cacheErr)
	}

	mongoCtx, mongoCancel := context.WithTimeout(ctx, settingsMongoReadTimeout)
	defer mongoCancel()
	var encrypted domain.UserSettings
	err := s.settings.FindOne(mongoCtx, &encrypted, bson.M{"uid": uid})
	if errors.Is(err, mon.ErrNotFound) || errors.Is(err, mongo.ErrNoDocuments) {
		s.writeSettingsCache(key, settingsCacheDTO{Version: settingsCacheVersion, UID: uid}, settingsNegativeCacheTTL)
		return domain.DefaultSettings(uid), nil
	}
	if err != nil {
		return nil, err
	}
	st, err = decryptSettings(s.secrets, uid, &encrypted)
	if err != nil {
		return nil, err
	}
	cacheValue := &encrypted
	if err := validateCachedCiphertext(cacheValue); err != nil {
		cacheValue, err = encryptSettings(s.secrets, uid, st)
		if err != nil {
			logx.Errorf("member settings cache legacy refill skipped uid=%d: %v", uid, err)
			return st, nil
		}
	}
	dto, err := newSettingsCacheDTO(cacheValue)
	if err == nil {
		s.writeSettingsCache(key, dto, settingsCacheTTL)
	}
	return st, nil
}

func (s *MoncStore) SaveSettings(ctx context.Context, uid int64, st *domain.UserSettings) error {
	encrypted, err := encryptSettings(s.secrets, uid, st)
	if err != nil {
		return err
	}
	encrypted.UID = uid
	encrypted.UpdatedAt = time.Now().UTC().UnixNano()
	_, err = s.settings.ReplaceOne(ctx, bson.M{"uid": uid}, encrypted, options.Replace().SetUpsert(true))
	if err != nil {
		return err
	}
	dto, cacheErr := newSettingsCacheDTO(encrypted)
	if cacheErr == nil {
		cacheErr = s.writeSettingsCache(settingsCacheKey(s.settingsCacheNamespace, uid), dto, settingsCacheTTL)
	}
	if cacheErr != nil {
		key := settingsCacheKey(s.settingsCacheNamespace, uid)
		cacheCtx, cancel := context.WithTimeout(context.Background(), settingsCacheTimeout)
		deleteErr := s.settingsCache.Delete(cacheCtx, key)
		cancel()
		logx.Errorf("CRITICAL: member settings saved to Mongo but Redis ciphertext cache write failed uid=%d cache_err=%v delete_err=%v", uid, cacheErr, deleteErr)
	}
	return nil
}

func (s *MoncStore) writeSettingsCache(key string, dto settingsCacheDTO, ttl time.Duration) error {
	raw, err := encodeSettingsCache(dto)
	if err != nil {
		return err
	}
	cacheCtx, cancel := context.WithTimeout(context.Background(), settingsCacheTimeout)
	defer cancel()
	if err := s.settingsCache.Set(cacheCtx, key, raw, ttl); err != nil {
		logx.Errorf("member settings cache refill failed key=%s: %v", key, err)
		return err
	}
	return nil
}

func durationSeconds(ttl time.Duration) int {
	seconds := int(ttl.Seconds())
	if seconds < 1 {
		return 1
	}
	return seconds
}

func encryptSettings(codec *secretbox.Codec, uid int64, st *domain.UserSettings) (*domain.UserSettings, error) {
	if st == nil {
		return nil, domain.ErrNotFound
	}
	out := cloneSettings(st)
	var err error
	out.APIKey, err = encryptSetting(codec, uid, "api_key", out.Provider, out.APIKey)
	if err != nil {
		return nil, err
	}
	for provider, key := range out.ProviderAPIKeys {
		out.ProviderAPIKeys[provider], err = encryptSetting(codec, uid, "provider_api_keys", provider, key)
		if err != nil {
			return nil, err
		}
	}
	out.ExaAPIKey, err = encryptSetting(codec, uid, "exa_api_key", "exa", out.ExaAPIKey)
	if err != nil {
		return nil, err
	}
	return out, nil
}

func decryptSettings(codec *secretbox.Codec, uid int64, st *domain.UserSettings) (*domain.UserSettings, error) {
	if st == nil {
		return nil, domain.ErrNotFound
	}
	out := cloneSettings(st)
	var err error
	out.APIKey, err = decryptSetting(codec, uid, "api_key", out.Provider, out.APIKey)
	if err != nil {
		return nil, err
	}
	for provider, key := range out.ProviderAPIKeys {
		out.ProviderAPIKeys[provider], err = decryptSetting(codec, uid, "provider_api_keys", provider, key)
		if err != nil {
			return nil, err
		}
	}
	out.ExaAPIKey, err = decryptSetting(codec, uid, "exa_api_key", "exa", out.ExaAPIKey)
	if err != nil {
		return nil, err
	}
	return out, nil
}

func encryptSetting(codec *secretbox.Codec, uid int64, field, provider, value string) (string, error) {
	if value == "" {
		return "", nil
	}
	if codec == nil {
		return "", fmt.Errorf("member settings encryption codec is required for %s", field)
	}
	return codec.Encrypt(uid, field, provider, value)
}

func decryptSetting(codec *secretbox.Codec, uid int64, field, provider, value string) (string, error) {
	if value == "" || !secretbox.IsEncrypted(value) {
		return value, nil
	}
	if codec == nil {
		return "", fmt.Errorf("member settings encryption codec is required for %s", field)
	}
	plaintext, err := codec.Decrypt(uid, field, provider, value)
	if err != nil {
		return "", fmt.Errorf("decrypt member settings %s: %w", field, err)
	}
	return plaintext, nil
}

func cloneSettings(st *domain.UserSettings) *domain.UserSettings {
	out := *st
	out.ProviderAPIKeys = cloneStringMap(st.ProviderAPIKeys)
	return &out
}

func cloneStringMap(values map[string]string) map[string]string {
	if values == nil {
		return nil
	}
	out := make(map[string]string, len(values))
	for key, value := range values {
		out[key] = value
	}
	return out
}
