// Package client_auth 提供 C 端认证业务逻辑
// 包含资产验证、微信登录、手机号绑定与退出登录等能力
package client_auth

import (
	"context"
	"regexp"
	"time"

	"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
	"github.com/break/junhong_cmp_fiber/internal/model"
	"github.com/break/junhong_cmp_fiber/internal/model/dto"
	customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
	"github.com/break/junhong_cmp_fiber/internal/service/verification"
	wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
	"github.com/break/junhong_cmp_fiber/internal/store/postgres"
	"github.com/break/junhong_cmp_fiber/pkg/auth"
	"github.com/break/junhong_cmp_fiber/pkg/config"
	"github.com/break/junhong_cmp_fiber/pkg/constants"
	"github.com/break/junhong_cmp_fiber/pkg/errors"
	"github.com/break/junhong_cmp_fiber/pkg/wechat"
	"github.com/golang-jwt/jwt/v5"
	"github.com/redis/go-redis/v9"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

const (
	assetTypeIotCard = "iot_card"
	assetTypeDevice  = "device"

	appTypeOfficialAccount = "official_account"
	appTypeMiniapp         = "miniapp"

	assetTokenExpireSeconds = 300
)

var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9_-]{1,50}$`)

// Service C 端认证服务
type Service struct {
	db                  *gorm.DB
	openidStore         *postgres.PersonalCustomerOpenIDStore
	customerStore       *postgres.PersonalCustomerStore
	phoneStore          *postgres.PersonalCustomerPhoneStore
	iotCardStore        *postgres.IotCardStore
	deviceStore         *postgres.DeviceStore
	wechatConfigService *wechatConfigSvc.Service
	verificationService *verification.Service
	jwtManager          *auth.JWTManager
	redis               *redis.Client
	logger              *zap.Logger
	wechatCache         kernel.CacheInterface
	customerBinding     *customerBinding.Service
}

// New 创建 C 端认证服务实例
func New(
	db *gorm.DB,
	openidStore *postgres.PersonalCustomerOpenIDStore,
	customerStore *postgres.PersonalCustomerStore,
	phoneStore *postgres.PersonalCustomerPhoneStore,
	iotCardStore *postgres.IotCardStore,
	deviceStore *postgres.DeviceStore,
	wechatConfigService *wechatConfigSvc.Service,
	verificationService *verification.Service,
	jwtManager *auth.JWTManager,
	redisClient *redis.Client,
	logger *zap.Logger,
	binding *customerBinding.Service,
) *Service {
	return &Service{
		db:                  db,
		openidStore:         openidStore,
		customerStore:       customerStore,
		phoneStore:          phoneStore,
		iotCardStore:        iotCardStore,
		deviceStore:         deviceStore,
		wechatConfigService: wechatConfigService,
		verificationService: verificationService,
		jwtManager:          jwtManager,
		redis:               redisClient,
		logger:              logger,
		wechatCache:         wechat.NewRedisCache(redisClient),
		customerBinding:     binding,
	}
}

type assetTokenClaims struct {
	AssetType string `json:"asset_type"`
	AssetID   uint   `json:"asset_id"`
	jwt.RegisteredClaims
}

// VerifyAsset A1 验证资产并签发短期资产令牌
func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) {
	if req == nil || !identifierRegex.MatchString(req.Identifier) {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil {
		return nil, err
	}

	assetType, assetID, shopID, err := s.resolveAsset(ctx, req.Identifier)
	if err != nil {
		return nil, err
	}

	// 如果是卡类型，检查是否绑定了设备
	// TODO: resolveAsset 可返回完整卡对象以避免二次查询，后续优化
	if assetType == assetTypeIotCard {
		if bound, checkErr := s.checkCardBoundToDevice(ctx, assetID); checkErr != nil {
			return nil, checkErr
		} else if bound {
			return nil, errors.New(errors.CodeForbidden, "该卡绑定了设备,请使用设备的虚拟号/设备号/IMEI/SN登录")
		}
	}
	if err := s.ensureShopClientLoginAllowed(ctx, shopID); err != nil {
		return nil, err
	}

	assetToken, err := s.signAssetToken(assetType, assetID)
	if err != nil {
		s.logger.Error("签发资产令牌失败", zap.Error(err))
		return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败")
	}

	resp := &dto.VerifyAssetResponse{
		AssetToken: assetToken,
		ExpiresIn:  assetTokenExpireSeconds,
	}

	if wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx); err == nil && wechatConfig != nil {
		resp.OaAppID = wechatConfig.OaAppID
		resp.MiniappAppID = wechatConfig.MiniappAppID
	}

	return resp, nil
}

// WechatLogin A2 公众号登录
func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
	if req == nil {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	assetClaims, err := s.verifyAssetToken(req.AssetToken)
	if err != nil {
		return nil, err
	}

	wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
	if err != nil {
		return nil, err
	}
	if wechatConfig == nil {
		return nil, errors.New(errors.CodeWechatConfigUnavailable)
	}

	oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
	if err != nil {
		s.logger.Error("创建公众号实例失败", zap.Error(err))
		return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用")
	}
	oaService := wechat.NewOfficialAccountService(oaApp, s.logger)

	userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code)
	if err != nil {
		return nil, err
	}

	customerID, isNewUser, err := s.loginByOpenID(
		ctx,
		assetClaims.AssetType,
		assetClaims.AssetID,
		wechatConfig.OaAppID,
		userInfo.OpenID,
		userInfo.UnionID,
		userInfo.Nickname,
		userInfo.Avatar,
		appTypeOfficialAccount,
	)
	if err != nil {
		return nil, err
	}

	token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID)
	if err != nil {
		return nil, err
	}

	s.logger.Info("公众号登录成功",
		zap.Uint("customer_id", customerID),
		zap.String("client_ip", clientIP),
	)

	return &dto.WechatLoginResponse{
		Token:         token,
		NeedBindPhone: needBindPhone,
		IsNewUser:     isNewUser,
	}, nil
}

// MiniappLogin A3 小程序登录
func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
	if req == nil {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	assetClaims, err := s.verifyAssetToken(req.AssetToken)
	if err != nil {
		return nil, err
	}

	wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
	if err != nil {
		return nil, err
	}
	if wechatConfig == nil {
		return nil, errors.New(errors.CodeWechatConfigUnavailable)
	}

	miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
	if err != nil {
		s.logger.Error("创建小程序服务失败", zap.Error(err))
		return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用")
	}

	openID, unionID, _, err := miniService.Code2Session(ctx, req.Code)
	if err != nil {
		return nil, err
	}

	customerID, isNewUser, err := s.loginByOpenID(
		ctx,
		assetClaims.AssetType,
		assetClaims.AssetID,
		wechatConfig.MiniappAppID,
		openID,
		unionID,
		req.Nickname,
		req.AvatarURL,
		appTypeMiniapp,
	)
	if err != nil {
		return nil, err
	}

	token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID)
	if err != nil {
		return nil, err
	}

	s.logger.Info("小程序登录成功",
		zap.Uint("customer_id", customerID),
		zap.String("client_ip", clientIP),
	)

	return &dto.WechatLoginResponse{
		Token:         token,
		NeedBindPhone: needBindPhone,
		IsNewUser:     isNewUser,
	}, nil
}

// SendCode A4 发送验证码
func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) {
	if req == nil || req.Phone == "" {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil {
		return nil, err
	}

	if err := s.verificationService.SendCode(ctx, req.Phone); err != nil {
		s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err))
		return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败")
	}

	cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone)
	if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil {
		s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err))
		return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败")
	}

	return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil
}

// BindPhone A5 绑定手机号
func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
	if req == nil {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
		return nil, errors.New(errors.CodeAlreadyBoundPhone)
	} else if err != gorm.ErrRecordNotFound {
		return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
	}

	if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
		return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
	}

	if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
		if existed.CustomerID != customerID {
			return nil, errors.New(errors.CodePhoneAlreadyBound)
		}
		return nil, errors.New(errors.CodeAlreadyBoundPhone)
	} else if err != gorm.ErrRecordNotFound {
		return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
	}

	now := time.Now()
	record := &model.PersonalCustomerPhone{
		CustomerID: customerID,
		Phone:      req.Phone,
		IsPrimary:  true,
		VerifiedAt: &now,
		Status:     1,
	}
	if err := s.phoneStore.Create(ctx, record); err != nil {
		return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
	}

	return &dto.BindPhoneResponse{
		Phone:   req.Phone,
		BoundAt: now.Format("2006-01-02 15:04:05"),
	}, nil
}

// ChangePhone A6 换绑手机号
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
	if req == nil {
		return nil, errors.New(errors.CodeInvalidParam)
	}

	primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
	if err == gorm.ErrRecordNotFound {
		return nil, errors.New(errors.CodeOldPhoneMismatch)
	}
	if err != nil {
		return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
	}

	if primary.Phone != req.OldPhone {
		return nil, errors.New(errors.CodeOldPhoneMismatch)
	}

	if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
		return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
	}
	if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
		return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
	}

	if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
		return nil, errors.New(errors.CodePhoneAlreadyBound)
	} else if err != nil && err != gorm.ErrRecordNotFound {
		return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
	}

	now := time.Now()
	if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
		Where("id = ? AND customer_id = ?", primary.ID, customerID).
		Updates(map[string]any{
			"phone":       req.NewPhone,
			"verified_at": now,
			"updated_at":  now,
		}).Error; err != nil {
		return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
	}

	return &dto.ChangePhoneResponse{
		Phone:     req.NewPhone,
		ChangedAt: now.Format("2006-01-02 15:04:05"),
	}, nil
}

// Logout A7 退出登录
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
	redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
	if err := s.redis.Del(ctx, redisKey).Err(); err != nil {
		return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败")
	}

	return &dto.LogoutResponse{Success: true}, nil
}

func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error {
	if clientIP == "" {
		return nil
	}

	key := constants.RedisClientAuthRateLimitIPKey(clientIP)
	count, err := s.redis.Incr(ctx, key).Result()
	if err != nil {
		return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败")
	}
	if count == 1 {
		if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil {
			return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败")
		}
	}
	if count > 30 {
		return errors.New(errors.CodeTooManyRequests)
	}
	return nil
}

func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, *uint, error) {
	var card model.IotCard
	if err := s.db.WithContext(ctx).
		Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier).
		First(&card).Error; err == nil {
		return assetTypeIotCard, card.ID, card.ShopID, nil
	} else if err != gorm.ErrRecordNotFound {
		return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
	}

	var device model.Device
	if err := s.db.WithContext(ctx).
		Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
		First(&device).Error; err == nil {
		return assetTypeDevice, device.ID, device.ShopID, nil
	} else if err != gorm.ErrRecordNotFound {
		return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
	}

	return "", 0, nil, errors.New(errors.CodeAssetNotFound)
}

// ensureShopClientLoginAllowed 在签发资产令牌前检查店铺的新登录限制。
func (s *Service) ensureShopClientLoginAllowed(ctx context.Context, shopID *uint) error {
	if shopID == nil {
		return nil
	}
	var shop model.Shop
	err := s.db.WithContext(ctx).Select("client_login_disabled").First(&shop, *shopID).Error
	if err == gorm.ErrRecordNotFound {
		return errors.New(errors.CodeForbidden, "资产所属店铺不可用")
	}
	if err != nil {
		return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺C端登录限制失败")
	}
	if shop.ClientLoginDisabled {
		return errors.New(errors.CodeForbidden, "该店铺暂不允许C端登录")
	}
	return nil
}

func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
	now := time.Now()
	claims := &assetTokenClaims{
		AssetType: assetType,
		AssetID:   assetID,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
			IssuedAt:  jwt.NewNumericDate(now),
			NotBefore: jwt.NewNumericDate(now),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	cfg := config.Get()
	if cfg == nil || cfg.JWT.SecretKey == "" {
		return "", errors.New(errors.CodeInternalError, "JWT 密钥未配置")
	}
	return token.SignedString([]byte(cfg.JWT.SecretKey + ":asset"))
}

func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) {
	if assetToken == "" {
		return nil, errors.New(errors.CodeInvalidParam)
	}
	cfg := config.Get()
	if cfg == nil || cfg.JWT.SecretKey == "" {
		return nil, errors.New(errors.CodeInternalError, "JWT 密钥未配置")
	}

	parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (any, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New(errors.CodeInvalidToken)
		}
		return []byte(cfg.JWT.SecretKey + ":asset"), nil
	})
	if err != nil {
		return nil, errors.New(errors.CodeInvalidToken)
	}

	claims, ok := parsed.Claims.(*assetTokenClaims)
	if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" {
		return nil, errors.New(errors.CodeInvalidToken)
	}

	return claims, nil
}

func (s *Service) loginByOpenID(
	ctx context.Context,
	assetType string,
	assetID uint,
	appID string,
	openID string,
	unionID string,
	nickname string,
	avatar string,
	appType string,
) (uint, bool, error) {
	var (
		customerID uint
		isNewUser  bool
	)

	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
		if findErr != nil {
			return findErr
		}
		if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
			return bindErr
		}

		customerID = cid
		isNewUser = created
		return nil
	})
	if err != nil {
		return 0, false, err
	}

	return customerID, isNewUser, nil
}

// findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户
func (s *Service) findOrCreateCustomer(
	ctx context.Context,
	tx *gorm.DB,
	appID string,
	openID string,
	unionID string,
	nickname string,
	avatar string,
	appType string,
) (uint, bool, error) {
	openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
	customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)

	if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil {
		customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
		if getErr != nil {
			if getErr == gorm.ErrRecordNotFound {
				return 0, false, errors.New(errors.CodeCustomerNotFound)
			}
			return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
		}
		if customer.Status == 0 {
			return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
		}

		if nickname != "" && customer.Nickname != nickname {
			customer.Nickname = nickname
		}
		if avatar != "" && customer.AvatarURL != avatar {
			customer.AvatarURL = avatar
		}
		if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
			return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
		}
		return customer.ID, false, nil
	} else if err != gorm.ErrRecordNotFound {
		return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
	}

	if unionID != "" {
		if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil {
			customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
			if getErr != nil {
				if getErr == gorm.ErrRecordNotFound {
					return 0, false, errors.New(errors.CodeCustomerNotFound)
				}
				return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
			}
			if customer.Status == 0 {
				return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
			}

			record := &model.PersonalCustomerOpenID{
				CustomerID: customer.ID,
				AppID:      appID,
				OpenID:     openID,
				UnionID:    unionID,
				AppType:    appType,
			}
			if createErr := openidStore.Create(ctx, record); createErr != nil {
				return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
			}

			if nickname != "" && customer.Nickname != nickname {
				customer.Nickname = nickname
			}
			if avatar != "" && customer.AvatarURL != avatar {
				customer.AvatarURL = avatar
			}
			if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
				return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
			}

			return customer.ID, false, nil
		} else if err != gorm.ErrRecordNotFound {
			return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
		}
	}

	newCustomer := &model.PersonalCustomer{
		WxOpenID:  openID,
		WxUnionID: unionID,
		Nickname:  nickname,
		AvatarURL: avatar,
		Status:    1,
	}
	if err := customerStore.Create(ctx, newCustomer); err != nil {
		return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
	}

	record := &model.PersonalCustomerOpenID{
		CustomerID: newCustomer.ID,
		AppID:      appID,
		OpenID:     openID,
		UnionID:    unionID,
		AppType:    appType,
	}
	if err := openidStore.Create(ctx, record); err != nil {
		return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
	}

	return newCustomer.ID, true, nil
}

// checkCardBoundToDevice 检查卡是否绑定了设备
// 返回 true 表示已绑定设备，false 表示未绑定
func (s *Service) checkCardBoundToDevice(ctx context.Context, cardID uint) (bool, error) {
	var card model.IotCard
	if err := s.db.WithContext(ctx).Select("is_standalone", "device_virtual_no").First(&card, cardID).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			return false, errors.New(errors.CodeAssetNotFound)
		}
		return false, errors.Wrap(errors.CodeInternalError, err, "查询卡绑定状态失败")
	}
	// is_standalone = false 表示已绑定设备，或者 device_virtual_no 不为空也表示已绑定
	return !card.IsStandalone || card.DeviceVirtualNo != "", nil
}

// bindAsset 委托 CustomerBinding 模块处理客户与资产的绑定关系
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
	return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID)
}

func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) {
	// 查询用户已绑定的主手机号，写入 JWT，供后续接口直接从 context 取用
	var boundPhone string
	needBindPhone := false
	cfg := config.Get()
	requirePhoneBinding := true
	if cfg != nil {
		requirePhoneBinding = cfg.Client.RequirePhoneBinding
	}
	primaryPhone, phoneErr := s.phoneStore.GetPrimaryPhone(ctx, customerID)
	if phoneErr == nil {
		boundPhone = primaryPhone.Phone
	} else if phoneErr != gorm.ErrRecordNotFound {
		return "", false, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败")
	} else if requirePhoneBinding {
		needBindPhone = true
	}

	token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID)
	if err != nil {
		return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败")
	}

	claims, err := s.jwtManager.VerifyPersonalCustomerToken(token)
	if err != nil {
		return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败")
	}

	ttl := time.Until(claims.ExpiresAt.Time)
	if ttl <= 0 {
		ttl = 24 * time.Hour
	}

	redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
	if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil {
		return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败")
	}

	return token, needBindPhone, nil
}

// DevLogin 开发环境测试登录
// 根据资产标识符查找或创建测试客户并直接签发 JWT，无需微信 OAuth
// ⚠️ 仅限 logging.development=true 时由路由层暴露，严禁生产环境调用
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
	assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
	if err != nil {
		return "", 0, false, err
	}

	var (
		customerID uint
		isNewUser  bool
	)

	// 在事务中查找或创建测试客户并绑定资产
	// 使用固定的开发测试 OpenID（dev_test_+identifier），避免重复创建
	err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		devOpenID := "dev_test_" + identifier
		devAppID := "dev_test_app"

		cid, created, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
		if findErr != nil {
			return findErr
		}
		if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
			return bindErr
		}
		customerID = cid
		isNewUser = created
		return nil
	})
	if err != nil {
		return "", 0, false, err
	}

	token, _, err := s.issueLoginToken(ctx, customerID, assetType, assetID)
	if err != nil {
		return "", 0, false, err
	}

	s.logger.Info("开发环境测试登录成功",
		zap.Uint("customer_id", customerID),
		zap.String("identifier", identifier),
	)

	return token, customerID, isNewUser, nil
}

func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error {
	phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone)
	exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result()
	if err != nil {
		return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败")
	}
	if exists > 0 {
		return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁，请稍后再试")
	}

	ipKey := constants.RedisClientSendCodeIPHourKey(clientIP)
	ipCount, err := s.redis.Incr(ctx, ipKey).Result()
	if err != nil {
		return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败")
	}
	if ipCount == 1 {
		if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil {
			return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败")
		}
	}
	if ipCount > 20 {
		return errors.New(errors.CodeTooManyRequests)
	}

	phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone)
	phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result()
	if err != nil {
		return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败")
	}
	if phoneDayCount == 1 {
		nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
		ttl := time.Until(nextDay)
		if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil {
			return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败")
		}
	}
	if phoneDayCount > 10 {
		return errors.New(errors.CodeTooManyRequests)
	}

	return nil
}
