package appconfig

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/integration/activityclient"
	"hyapp-admin-server/internal/integration/userclient"
	"hyapp-admin-server/internal/model"
	"hyapp-admin-server/internal/repository"
	activityv1 "hyapp.local/api/proto/activity/v1"

	"gorm.io/gorm"
)

const h5LinkGroup = "h5-links"

const (
	bannerDisplayScopeHome     = "home"
	bannerDisplayScopeRoom     = "room"
	bannerDisplayScopeDiscover = "discover"
	bannerDisplayScopeRecharge = "recharge"
	bannerDisplayScopeMe       = "me"

	bannerStatusActive   = "active"
	bannerStatusDisabled = "disabled"
	bannerStatusExpired  = "expired"

	splashDefaultDisplayDurationMS = 3000
)

var bannerDisplayScopeOrder = []string{
	bannerDisplayScopeHome,
	bannerDisplayScopeRoom,
	bannerDisplayScopeDiscover,
	bannerDisplayScopeRecharge,
	bannerDisplayScopeMe,
}

type AppConfigService struct {
	store             *repository.Store
	activity          activityclient.Client
	landing           ActivityLandingURLBuilder
	roleScopePolicies RoleScopePolicyClient
	regionReader      interface {
		GetRegion(context.Context, userclient.GetRegionRequest) (*userclient.Region, error)
	}
}

func (s *AppConfigService) BindRoleScopePolicyClient(client RoleScopePolicyClient) *AppConfigService {
	if s != nil {
		s.roleScopePolicies = client
		if reader, ok := client.(interface {
			GetRegion(context.Context, userclient.GetRegionRequest) (*userclient.Region, error)
		}); ok {
			s.regionReader = reader
		}
	}
	return s
}

// ActivityLandingURLBuilder keeps COS/CDN placement outside banner business logic.
// The relation is durable while the derived h5 param remains backward compatible.
type ActivityLandingURLBuilder interface {
	URL(appCode string, templateCode string) (string, error)
}

type H5Link struct {
	AppCode     string `json:"appCode"`
	Key         string `json:"key"`
	Label       string `json:"label"`
	URL         string `json:"url"`
	UpdatedAtMs int64  `json:"updatedAtMs"`
}

type AppBanner struct {
	ID                   uint     `json:"id"`
	AppCode              string   `json:"appCode"`
	CoverURL             string   `json:"coverUrl"`
	RoomSmallImageURL    string   `json:"roomSmallImageUrl"`
	DiscoverImageURL     string   `json:"discoverImageUrl"`
	BannerType           string   `json:"bannerType"`
	TargetType           string   `json:"targetType"`
	ActivityTemplateID   string   `json:"activityTemplateId"`
	ActivityTemplateCode string   `json:"activityTemplateCode"`
	DisplayScope         string   `json:"displayScope"`
	DisplayScopes        []string `json:"displayScopes"`
	Param                string   `json:"param"`
	Status               string   `json:"status"`
	Platform             string   `json:"platform"`
	SortOrder            int      `json:"sortOrder"`
	RegionID             int64    `json:"regionId"`
	CountryCode          string   `json:"countryCode"`
	Description          string   `json:"description"`
	StartsAtMs           int64    `json:"startsAtMs"`
	EndsAtMs             int64    `json:"endsAtMs"`
	CreatedAtMs          int64    `json:"createdAtMs"`
	UpdatedAtMs          int64    `json:"updatedAtMs"`
}

type AppSplashScreen struct {
	ID                uint   `json:"id"`
	AppCode           string `json:"appCode"`
	CoverURL          string `json:"coverUrl"`
	SplashType        string `json:"splashType"`
	Param             string `json:"param"`
	Status            string `json:"status"`
	Platform          string `json:"platform"`
	SortOrder         int    `json:"sortOrder"`
	RegionID          int64  `json:"regionId"`
	CountryCode       string `json:"countryCode"`
	Description       string `json:"description"`
	DisplayDurationMs int    `json:"displayDurationMs"`
	StartsAtMs        int64  `json:"startsAtMs"`
	EndsAtMs          int64  `json:"endsAtMs"`
	CreatedAtMs       int64  `json:"createdAtMs"`
	UpdatedAtMs       int64  `json:"updatedAtMs"`
}

type AppPopup struct {
	ID                uint   `json:"id"`
	AppCode           string `json:"appCode"`
	Code              string `json:"code"`
	Name              string `json:"name"`
	ImageURL          string `json:"imageUrl"`
	JumpType          string `json:"jumpType"`
	JumpURL           string `json:"jumpUrl"`
	DisplayPeriodDays int    `json:"displayPeriodDays"`
	Status            string `json:"status"`
	SortOrder         int    `json:"sortOrder"`
	StartsAtMs        int64  `json:"startsAtMs"`
	EndsAtMs          int64  `json:"endsAtMs"`
	CreatedAtMs       int64  `json:"createdAtMs"`
	UpdatedAtMs       int64  `json:"updatedAtMs"`
}

type AppVersion struct {
	ID          uint   `json:"id"`
	AppCode     string `json:"appCode"`
	Platform    string `json:"platform"`
	Version     string `json:"version"`
	BuildNumber int64  `json:"buildNumber"`
	ForceUpdate bool   `json:"forceUpdate"`
	DownloadURL string `json:"downloadUrl"`
	Description string `json:"description"`
	CreatedAtMs int64  `json:"createdAtMs"`
	UpdatedAtMs int64  `json:"updatedAtMs"`
}

type ExploreTab struct {
	ID          uint   `json:"id"`
	AppCode     string `json:"appCode"`
	Tab         string `json:"tab"`
	H5URL       string `json:"h5Url"`
	Enabled     bool   `json:"enabled"`
	SortOrder   int    `json:"sortOrder"`
	CreatedAtMs int64  `json:"createdAtMs"`
	UpdatedAtMs int64  `json:"updatedAtMs"`
}

func NewService(store *repository.Store) *AppConfigService {
	return &AppConfigService{store: store}
}

func (s *AppConfigService) BindActivityTemplateTarget(activity activityclient.Client, landing ActivityLandingURLBuilder) *AppConfigService {
	if s != nil {
		s.activity = activity
		s.landing = landing
	}
	return s
}

func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) {
	appCode = appctx.Normalize(appCode)
	// H5 是 App 品牌和运营内容的一部分，未配置时必须返回空列表，不能继承其他 App 的历史链接。
	configs, err := s.store.ListOwnedAppConfigs(appCode, h5LinkGroup)
	if err != nil {
		return nil, err
	}

	items := make([]H5Link, 0, len(configs))
	for _, config := range configs {
		items = append(items, h5LinkFromModel(config))
	}
	return items, nil
}

func (s *AppConfigService) UpdateH5Links(appCode string, request updateH5LinksRequest) ([]H5Link, error) {
	appCode = appctx.Normalize(appCode)
	if len(request.Items) == 0 {
		return nil, errors.New("items are required")
	}

	items := make([]model.AppConfig, 0, len(request.Items))
	seen := make(map[string]struct{}, len(request.Items))
	for _, item := range request.Items {
		config, err := h5LinkModelFromPayload(appCode, item)
		if err != nil {
			return nil, err
		}
		key := config.Key
		if _, ok := seen[key]; ok {
			return nil, fmt.Errorf("duplicate h5 link key: %s", key)
		}
		seen[key] = struct{}{}
		items = append(items, config)
	}

	if err := s.store.UpsertScopedAppConfigs(appCode, items); err != nil {
		return nil, err
	}
	return s.ListH5Links(appCode)
}

func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Link, error) {
	appCode = appctx.Normalize(appCode)
	item, err := h5LinkModelFromPayload(appCode, req)
	if err != nil {
		return H5Link{}, err
	}
	if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key); err == nil {
		return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key)
	} else if !errors.Is(err, gorm.ErrRecordNotFound) {
		return H5Link{}, err
	}
	// upsert 只以三元唯一键写当前 App，重试不会触碰其他 App 的同名 H5 key。
	if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil {
		return H5Link{}, err
	}
	stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key)
	if err != nil {
		return H5Link{}, err
	}
	return h5LinkFromModel(stored), nil
}

func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) {
	appCode = appctx.Normalize(appCode)
	item, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key)
	if err != nil {
		return H5Link{}, err
	}
	req.Key = item.Key
	updated, err := h5LinkModelFromPayload(appCode, req)
	if err != nil {
		return H5Link{}, err
	}
	// 请求作用域决定 app_code；请求体只能更新当前 App 已存在的 key。
	if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil {
		return H5Link{}, err
	}
	stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, updated.Key)
	if err != nil {
		return H5Link{}, err
	}
	return h5LinkFromModel(stored), nil
}

func (s *AppConfigService) DeleteH5Link(appCode string, key string) error {
	appCode = appctx.Normalize(appCode)
	if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key); err != nil {
		return err
	}
	return s.store.DeleteOwnedAppConfig(appCode, h5LinkGroup, key)
}

func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
	options.AppCode = appctx.Normalize(appCode)
	options.Status = normalizeBannerStatus(options.Status)
	if strings.TrimSpace(options.DisplayScope) != "" {
		options.DisplayScope = normalizeBannerDisplayScope(options.DisplayScope)
	}
	options.Platform = normalizeBannerPlatform(options.Platform)
	options.Country = normalizeCountryCode(options.Country)
	options.Keyword = strings.TrimSpace(options.Keyword)
	if err := s.store.ExpireAppBanners(options.AppCode, options.RegionID, time.Now().UTC().UnixMilli()); err != nil {
		return nil, err
	}
	items, err := s.store.ListAppBanners(options)
	if err != nil {
		return nil, err
	}
	out := make([]AppBanner, 0, len(items))
	for _, item := range items {
		out = append(out, appBannerFromModel(item))
	}
	return out, nil
}

func (s *AppConfigService) CreateBanner(ctx context.Context, appCode string, req bannerRequest) (AppBanner, error) {
	if scope, external := appctx.ExternalScopeFromContext(ctx); external {
		req.RegionID = scope.RegionID
		if err := s.validateExternalBannerCountry(ctx, req.CountryCode, scope.RegionID); err != nil {
			return AppBanner{}, err
		}
	}
	var err error
	req, err = s.resolveBannerTarget(ctx, appctx.Normalize(appCode), req)
	if err != nil {
		return AppBanner{}, err
	}
	item, err := bannerModelFromRequest(appctx.Normalize(appCode), req)
	if err != nil {
		return AppBanner{}, err
	}
	if err := s.store.CreateAppBanner(&item); err != nil {
		return AppBanner{}, err
	}
	return appBannerFromModel(item), nil
}

func (s *AppConfigService) UpdateBanner(ctx context.Context, appCode string, id uint, req bannerRequest) (AppBanner, error) {
	item, err := s.store.GetAppBanner(appctx.Normalize(appCode), id)
	if err != nil {
		return AppBanner{}, err
	}
	if scope, external := appctx.ExternalScopeFromContext(ctx); external {
		if item.RegionID != scope.RegionID {
			return AppBanner{}, errors.New("banner does not belong to current region")
		}
		req.RegionID = scope.RegionID
		if err := s.validateExternalBannerCountry(ctx, req.CountryCode, scope.RegionID); err != nil {
			return AppBanner{}, err
		}
	}
	req, err = s.resolveBannerTarget(ctx, item.AppCode, req)
	if err != nil {
		return AppBanner{}, err
	}
	updated, err := bannerModelFromRequest(item.AppCode, req)
	if err != nil {
		return AppBanner{}, err
	}
	item.CoverURL = updated.CoverURL
	item.RoomSmallImageURL = updated.RoomSmallImageURL
	item.DiscoverImageURL = updated.DiscoverImageURL
	item.BannerType = updated.BannerType
	item.TargetType = updated.TargetType
	item.ActivityTemplateID = updated.ActivityTemplateID
	item.ActivityTemplateCode = updated.ActivityTemplateCode
	item.DisplayScope = updated.DisplayScope
	item.Param = updated.Param
	item.Status = updated.Status
	item.Platform = updated.Platform
	item.SortOrder = updated.SortOrder
	item.RegionID = updated.RegionID
	item.CountryCode = updated.CountryCode
	item.Description = updated.Description
	item.StartsAtMS = updated.StartsAtMS
	item.EndsAtMS = updated.EndsAtMS
	if err := s.store.UpdateAppBanner(&item); err != nil {
		return AppBanner{}, err
	}
	return appBannerFromModel(item), nil
}

// resolveBannerTarget turns an admin-only template relation into the legacy h5
// contract consumed by existing clients. Validation is performed against the
// owner service at save time so a cross-App, disabled, expired, out-of-region,
// or out-of-window template can never become an active entry point.
func (s *AppConfigService) resolveBannerTarget(ctx context.Context, appCode string, req bannerRequest) (bannerRequest, error) {
	targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
	if targetType == "" {
		targetType = normalizeBannerType(req.BannerType)
	}
	if targetType != "activity_template" {
		if targetType != "h5" && targetType != "app" {
			return bannerRequest{}, errors.New("banner target type is invalid")
		}
		req.TargetType = targetType
		req.ActivityTemplateID = ""
		req.ActivityTemplateCode = ""
		return req, nil
	}
	if s.activity == nil || s.landing == nil {
		return bannerRequest{}, errors.New("activity template banner target is unavailable")
	}
	templateID := strings.TrimSpace(req.ActivityTemplateID)
	if templateID == "" {
		return bannerRequest{}, errors.New("activity template is required")
	}
	resp, err := s.activity.GetActivityTemplate(ctx, &activityv1.GetActivityTemplateRequest{
		Meta:       &activityv1.RequestMeta{Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli()},
		TemplateId: templateID,
	})
	if err != nil {
		return bannerRequest{}, fmt.Errorf("get activity template: %w", err)
	}
	template := resp.GetTemplate()
	if template == nil || appctx.Normalize(template.GetAppCode()) != appCode {
		return bannerRequest{}, errors.New("activity template does not belong to current app")
	}
	if template.GetActivityType() != "gift_challenge" || template.GetStatus() != "published" {
		return bannerRequest{}, errors.New("only published gift challenge templates can be selected")
	}
	if lifecycle := template.GetLifecycleStatus(); lifecycle != "scheduled" && lifecycle != "ongoing" {
		return bannerRequest{}, errors.New("activity template is not currently available")
	}
	if req.StartsAtMs <= 0 || req.EndsAtMs <= 0 || req.StartsAtMs < template.GetStartMs() || req.EndsAtMs > template.GetEndMs() {
		return bannerRequest{}, errors.New("banner delivery time must be inside activity template time")
	}
	if !template.GetAllRegions() && !containsInt64(template.GetRegionIds(), req.RegionID) {
		return bannerRequest{}, errors.New("banner region must be enabled by activity template")
	}
	landingURL, err := s.landing.URL(appCode, template.GetTemplateCode())
	if err != nil {
		return bannerRequest{}, fmt.Errorf("build activity template landing url: %w", err)
	}
	req.TargetType = "activity_template"
	req.ActivityTemplateID = template.GetTemplateId()
	req.ActivityTemplateCode = template.GetTemplateCode()
	req.BannerType = "h5"
	req.Param = landingURL
	return req, nil
}

func containsInt64(items []int64, target int64) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

func (s *AppConfigService) DeleteBanner(ctx context.Context, appCode string, id uint) error {
	if scope, external := appctx.ExternalScopeFromContext(ctx); external {
		item, err := s.store.GetAppBanner(appctx.Normalize(appCode), id)
		if err != nil {
			return err
		}
		if item.RegionID != scope.RegionID {
			return errors.New("banner does not belong to current region")
		}
	}
	return s.store.DeleteAppBanner(appctx.Normalize(appCode), id)
}

func (s *AppConfigService) validateExternalBannerCountry(ctx context.Context, countryCode string, regionID int64) error {
	countryCode = normalizeCountryCode(countryCode)
	if countryCode == "" {
		return nil
	}
	if s.regionReader == nil || regionID <= 0 {
		return errors.New("region service is unavailable")
	}
	region, err := s.regionReader.GetRegion(ctx, userclient.GetRegionRequest{
		RequestID: "external-banner-country-check", Caller: "hyapp-admin-external", RegionID: regionID,
	})
	if err != nil {
		return err
	}
	for _, allowedCountry := range region.Countries {
		if normalizeCountryCode(allowedCountry) == countryCode {
			return nil
		}
	}
	return errors.New("banner country does not belong to current region")
}

func (s *AppConfigService) ListSplashScreens(appCode string, options repository.AppSplashScreenListOptions) ([]AppSplashScreen, error) {
	// 列表入口先把管理后台传入的筛选条件统一成数据库稳定值，再执行过期回收，避免已过期 active 数据继续展示。
	options.AppCode = appctx.Normalize(appCode)
	options.Status = normalizeBannerStatus(options.Status)
	options.Platform = normalizeBannerPlatform(options.Platform)
	options.Country = normalizeCountryCode(options.Country)
	options.Keyword = strings.TrimSpace(options.Keyword)
	if err := s.store.ExpireAppSplashScreens(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
		return nil, err
	}
	items, err := s.store.ListAppSplashScreens(options)
	if err != nil {
		return nil, err
	}
	out := make([]AppSplashScreen, 0, len(items))
	for _, item := range items {
		out = append(out, appSplashScreenFromModel(item))
	}
	return out, nil
}

func (s *AppConfigService) CreateSplashScreen(appCode string, req splashScreenRequest) (AppSplashScreen, error) {
	// 创建时只接受 service 归一化后的模型，保证 status、平台、国家码和时间窗口在入库前已经被校验。
	item, err := splashScreenModelFromRequest(appctx.Normalize(appCode), req)
	if err != nil {
		return AppSplashScreen{}, err
	}
	if err := s.store.CreateAppSplashScreen(&item); err != nil {
		return AppSplashScreen{}, err
	}
	return appSplashScreenFromModel(item), nil
}

func (s *AppConfigService) UpdateSplashScreen(appCode string, id uint, req splashScreenRequest) (AppSplashScreen, error) {
	// 更新先按 app_code 和 id 锁定原记录，再只覆盖业务字段，避免请求体伪造 app_code 或创建时间。
	item, err := s.store.GetAppSplashScreen(appctx.Normalize(appCode), id)
	if err != nil {
		return AppSplashScreen{}, err
	}
	updated, err := splashScreenModelFromRequest(item.AppCode, req)
	if err != nil {
		return AppSplashScreen{}, err
	}
	item.CoverURL = updated.CoverURL
	item.SplashType = updated.SplashType
	item.Param = updated.Param
	item.Status = updated.Status
	item.Platform = updated.Platform
	item.SortOrder = updated.SortOrder
	item.RegionID = updated.RegionID
	item.CountryCode = updated.CountryCode
	item.Description = updated.Description
	item.DisplayDurationMS = updated.DisplayDurationMS
	item.StartsAtMS = updated.StartsAtMS
	item.EndsAtMS = updated.EndsAtMS
	if err := s.store.UpdateAppSplashScreen(&item); err != nil {
		return AppSplashScreen{}, err
	}
	return appSplashScreenFromModel(item), nil
}

func (s *AppConfigService) DeleteSplashScreen(appCode string, id uint) error {
	return s.store.DeleteAppSplashScreen(appctx.Normalize(appCode), id)
}

func (s *AppConfigService) ListPopups(appCode string, options repository.AppPopupListOptions) ([]AppPopup, error) {
	// 列表先清理已过期 active 记录，再返回后台可见数据，保证运营看到的状态和 App 实际可展示状态一致。
	options.AppCode = appctx.Normalize(appCode)
	options.Status = normalizeBannerStatus(options.Status)
	options.Keyword = strings.TrimSpace(options.Keyword)
	if err := s.store.ExpireAppPopups(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
		return nil, err
	}
	items, err := s.store.ListAppPopups(options)
	if err != nil {
		return nil, err
	}
	out := make([]AppPopup, 0, len(items))
	for _, item := range items {
		out = append(out, appPopupFromModel(item))
	}
	return out, nil
}

func (s *AppConfigService) CreatePopup(appCode string, req popupRequest) (AppPopup, error) {
	item, err := popupModelFromRequest(appctx.Normalize(appCode), req)
	if err != nil {
		return AppPopup{}, err
	}
	exists, err := s.store.HasAppPopupCode(item.AppCode, item.Code, 0)
	if err != nil {
		return AppPopup{}, err
	}
	if exists {
		return AppPopup{}, errors.New("popup code already exists")
	}
	if err := s.store.CreateAppPopup(&item); err != nil {
		return AppPopup{}, err
	}
	return appPopupFromModel(item), nil
}

func (s *AppConfigService) UpdatePopup(appCode string, id uint, req popupRequest) (AppPopup, error) {
	// 更新时先按 app_code 锁定原记录，再覆盖可编辑字段，避免请求体伪造租户和创建时间。
	item, err := s.store.GetAppPopup(appctx.Normalize(appCode), id)
	if err != nil {
		return AppPopup{}, err
	}
	updated, err := popupModelFromRequest(item.AppCode, req)
	if err != nil {
		return AppPopup{}, err
	}
	exists, err := s.store.HasAppPopupCode(item.AppCode, updated.Code, item.ID)
	if err != nil {
		return AppPopup{}, err
	}
	if exists {
		return AppPopup{}, errors.New("popup code already exists")
	}
	item.Code = updated.Code
	item.Name = updated.Name
	item.ImageURL = updated.ImageURL
	item.JumpType = updated.JumpType
	item.JumpURL = updated.JumpURL
	item.DisplayPeriodDays = updated.DisplayPeriodDays
	item.Status = updated.Status
	item.SortOrder = updated.SortOrder
	item.StartsAtMS = updated.StartsAtMS
	item.EndsAtMS = updated.EndsAtMS
	if err := s.store.UpdateAppPopup(&item); err != nil {
		return AppPopup{}, err
	}
	return appPopupFromModel(item), nil
}

func (s *AppConfigService) DeletePopup(appCode string, id uint) error {
	return s.store.DeleteAppPopup(appctx.Normalize(appCode), id)
}

func (s *AppConfigService) ListAppVersions(appCode string, options repository.AppVersionListOptions) ([]AppVersion, error) {
	options.AppCode = appctx.Normalize(appCode)
	options.Platform = normalizeAppPlatform(options.Platform)
	options.Keyword = strings.TrimSpace(options.Keyword)
	items, err := s.store.ListAppVersions(options)
	if err != nil {
		return nil, err
	}
	out := make([]AppVersion, 0, len(items))
	for _, item := range items {
		out = append(out, appVersionFromModel(item))
	}
	return out, nil
}

func (s *AppConfigService) CreateAppVersion(appCode string, req appVersionRequest) (AppVersion, error) {
	item, err := appVersionModelFromRequest(appctx.Normalize(appCode), req)
	if err != nil {
		return AppVersion{}, err
	}
	if err := s.store.CreateAppVersion(&item); err != nil {
		return AppVersion{}, err
	}
	return appVersionFromModel(item), nil
}

func (s *AppConfigService) UpdateAppVersion(appCode string, id uint, req appVersionRequest) (AppVersion, error) {
	item, err := s.store.GetAppVersion(appctx.Normalize(appCode), id)
	if err != nil {
		return AppVersion{}, err
	}
	updated, err := appVersionModelFromRequest(item.AppCode, req)
	if err != nil {
		return AppVersion{}, err
	}
	item.Platform = updated.Platform
	item.Version = updated.Version
	item.BuildNumber = updated.BuildNumber
	item.ForceUpdate = updated.ForceUpdate
	item.DownloadURL = updated.DownloadURL
	item.Description = updated.Description
	if err := s.store.UpdateAppVersion(&item); err != nil {
		return AppVersion{}, err
	}
	return appVersionFromModel(item), nil
}

func (s *AppConfigService) DeleteAppVersion(appCode string, id uint) error {
	return s.store.DeleteAppVersion(appctx.Normalize(appCode), id)
}

func (s *AppConfigService) ListExploreTabs(appCode string, options repository.AppExploreTabListOptions) ([]ExploreTab, error) {
	options.AppCode = appctx.Normalize(appCode)
	options.Keyword = strings.TrimSpace(options.Keyword)
	items, err := s.store.ListAppExploreTabs(options)
	if err != nil {
		return nil, err
	}
	out := make([]ExploreTab, 0, len(items))
	for _, item := range items {
		out = append(out, exploreTabFromModel(item))
	}
	return out, nil
}

func (s *AppConfigService) CreateExploreTab(appCode string, req exploreTabRequest) (ExploreTab, error) {
	item, err := exploreTabModelFromRequest(appctx.Normalize(appCode), req)
	if err != nil {
		return ExploreTab{}, err
	}
	if err := s.store.CreateAppExploreTab(&item); err != nil {
		return ExploreTab{}, err
	}
	return exploreTabFromModel(item), nil
}

func (s *AppConfigService) UpdateExploreTab(appCode string, id uint, req exploreTabRequest) (ExploreTab, error) {
	item, err := s.store.GetAppExploreTab(appctx.Normalize(appCode), id)
	if err != nil {
		return ExploreTab{}, err
	}
	updated, err := exploreTabModelFromRequest(item.AppCode, req)
	if err != nil {
		return ExploreTab{}, err
	}
	item.Tab = updated.Tab
	item.H5URL = updated.H5URL
	item.Enabled = updated.Enabled
	item.SortOrder = updated.SortOrder
	if err := s.store.UpdateAppExploreTab(&item); err != nil {
		return ExploreTab{}, err
	}
	return exploreTabFromModel(item), nil
}

func (s *AppConfigService) DeleteExploreTab(appCode string, id uint) error {
	return s.store.DeleteAppExploreTab(appctx.Normalize(appCode), id)
}

func validateH5URL(value string) error {
	if value == "" {
		return nil
	}
	if len(value) > 2048 {
		return errors.New("h5 link is too long")
	}
	for _, char := range value {
		if unicode.IsSpace(char) {
			return errors.New("h5 link cannot contain whitespace")
		}
	}
	return nil
}

func h5LinkModelFromPayload(appCode string, req h5LinkPayload) (model.AppConfig, error) {
	key := strings.TrimSpace(req.Key)
	label := strings.TrimSpace(req.Label)
	url := strings.TrimSpace(req.URL)
	if err := validateH5Key(key); err != nil {
		return model.AppConfig{}, err
	}
	if label == "" || utf8.RuneCountInString(label) > 80 {
		return model.AppConfig{}, errors.New("h5 config label is invalid")
	}
	if url == "" {
		return model.AppConfig{}, errors.New("h5 link is required")
	}
	if err := validateH5URL(url); err != nil {
		return model.AppConfig{}, err
	}
	return model.AppConfig{
		AppCode:     appctx.Normalize(appCode),
		Group:       h5LinkGroup,
		Key:         key,
		Value:       url,
		Description: label,
	}, nil
}

func validateH5Key(value string) error {
	if value == "" || len(value) > 80 {
		return errors.New("h5 config key is invalid")
	}
	for _, char := range value {
		switch {
		case char >= 'a' && char <= 'z':
		case char >= 'A' && char <= 'Z':
		case char >= '0' && char <= '9':
		case char == '-' || char == '_' || char == '.' || char == ':':
		default:
			return errors.New("h5 config key is invalid")
		}
	}
	return nil
}

func h5LinkFromModel(config model.AppConfig) H5Link {
	label := strings.TrimSpace(config.Description)
	if label == "" {
		label = config.Key
	}
	return H5Link{
		AppCode:     appctx.Normalize(config.AppCode),
		Key:         config.Key,
		Label:       label,
		URL:         config.Value,
		UpdatedAtMs: config.UpdatedAtMS,
	}
}

func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
	displayScopes := normalizeBannerDisplayScopes(req.DisplayScopes, req.DisplayScope)
	item := model.AppBanner{
		AppCode:              appctx.Normalize(appCode),
		CoverURL:             strings.TrimSpace(req.CoverURL),
		RoomSmallImageURL:    strings.TrimSpace(req.RoomSmallImageURL),
		DiscoverImageURL:     strings.TrimSpace(req.DiscoverImageURL),
		BannerType:           normalizeBannerType(req.BannerType),
		TargetType:           strings.ToLower(strings.TrimSpace(req.TargetType)),
		ActivityTemplateID:   strings.TrimSpace(req.ActivityTemplateID),
		ActivityTemplateCode: strings.TrimSpace(req.ActivityTemplateCode),
		DisplayScope:         bannerDisplayScopeValue(displayScopes),
		Param:                strings.TrimSpace(req.Param),
		Status:               normalizeBannerStatus(req.Status),
		Platform:             normalizeBannerPlatform(req.Platform),
		SortOrder:            req.SortOrder,
		RegionID:             req.RegionID,
		CountryCode:          normalizeCountryCode(req.CountryCode),
		Description:          strings.TrimSpace(req.Description),
		StartsAtMS:           req.StartsAtMs,
		EndsAtMS:             req.EndsAtMs,
	}
	if item.CoverURL == "" || len(item.CoverURL) > 1024 {
		return model.AppBanner{}, errors.New("banner cover is invalid")
	}
	if containsWhitespace(item.CoverURL) {
		return model.AppBanner{}, errors.New("banner cover cannot contain whitespace")
	}
	if !validBannerDisplayScopes(displayScopes) {
		return model.AppBanner{}, errors.New("banner display scope is invalid")
	}
	if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeRoom) {
		if item.RoomSmallImageURL == "" || len(item.RoomSmallImageURL) > 1024 {
			return model.AppBanner{}, errors.New("room small image is required")
		}
		if containsWhitespace(item.RoomSmallImageURL) {
			return model.AppBanner{}, errors.New("room small image cannot contain whitespace")
		}
	} else {
		// 未投放到房间内时不保留小屏图，避免客户端误用房间专属素材。
		item.RoomSmallImageURL = ""
	}
	if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeDiscover) {
		// Discover 使用独立的 355×100 横幅素材；保存时强制完整 URL，避免客户端回退到比例不同的首页封面。
		if item.DiscoverImageURL == "" || len(item.DiscoverImageURL) > 1024 {
			return model.AppBanner{}, errors.New("discover image is required")
		}
		if containsWhitespace(item.DiscoverImageURL) {
			return model.AppBanner{}, errors.New("discover image cannot contain whitespace")
		}
	} else {
		// 取消发现页投放时同步清空专属图，避免后续编辑误以为该素材仍在生效。
		item.DiscoverImageURL = ""
	}
	if item.BannerType != "h5" && item.BannerType != "app" {
		return model.AppBanner{}, errors.New("banner type is invalid")
	}
	if item.BannerType == "h5" {
		if item.Param == "" {
			return model.AppBanner{}, errors.New("h5 link is required")
		}
		if err := validateH5URL(item.Param); err != nil {
			return model.AppBanner{}, err
		}
	}
	if len(item.Param) > 2048 {
		return model.AppBanner{}, errors.New("banner param is too long")
	}
	if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
		return model.AppBanner{}, errors.New("banner status is invalid")
	}
	if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
		return model.AppBanner{}, errors.New("banner delivery time is invalid")
	}
	if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
		return model.AppBanner{}, errors.New("banner delivery time range is invalid")
	}
	if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
		item.Status = bannerStatusExpired
	}
	if item.Platform != "android" && item.Platform != "ios" {
		return model.AppBanner{}, errors.New("banner platform is invalid")
	}
	if item.RegionID < 0 {
		return model.AppBanner{}, errors.New("banner region is invalid")
	}
	if item.CountryCode != "" && !validCountryCode(item.CountryCode) {
		return model.AppBanner{}, errors.New("banner country is invalid")
	}
	if utf8.RuneCountInString(item.Description) > 255 {
		return model.AppBanner{}, errors.New("banner description is too long")
	}
	return item, nil
}

func appBannerFromModel(item model.AppBanner) AppBanner {
	return AppBanner{
		ID:                   item.ID,
		AppCode:              item.AppCode,
		CoverURL:             item.CoverURL,
		RoomSmallImageURL:    item.RoomSmallImageURL,
		DiscoverImageURL:     item.DiscoverImageURL,
		BannerType:           item.BannerType,
		TargetType:           item.TargetType,
		ActivityTemplateID:   item.ActivityTemplateID,
		ActivityTemplateCode: item.ActivityTemplateCode,
		DisplayScope:         item.DisplayScope,
		DisplayScopes:        bannerDisplayScopeList(item.DisplayScope),
		Param:                item.Param,
		Status:               item.Status,
		Platform:             item.Platform,
		SortOrder:            item.SortOrder,
		RegionID:             item.RegionID,
		CountryCode:          item.CountryCode,
		Description:          item.Description,
		StartsAtMs:           item.StartsAtMS,
		EndsAtMs:             item.EndsAtMS,
		CreatedAtMs:          item.CreatedAtMS,
		UpdatedAtMs:          item.UpdatedAtMS,
	}
}

func splashScreenModelFromRequest(appCode string, req splashScreenRequest) (model.AppSplashScreen, error) {
	item := model.AppSplashScreen{
		AppCode:           appctx.Normalize(appCode),
		CoverURL:          strings.TrimSpace(req.CoverURL),
		SplashType:        normalizeSplashType(req.SplashType),
		Param:             strings.TrimSpace(req.Param),
		Status:            normalizeBannerStatus(req.Status),
		Platform:          normalizeBannerPlatform(req.Platform),
		SortOrder:         req.SortOrder,
		RegionID:          req.RegionID,
		CountryCode:       normalizeCountryCode(req.CountryCode),
		Description:       strings.TrimSpace(req.Description),
		DisplayDurationMS: normalizeSplashDisplayDurationMS(req.DisplayDurationMs),
		StartsAtMS:        req.StartsAtMs,
		EndsAtMS:          req.EndsAtMs,
	}
	if item.CoverURL == "" || len(item.CoverURL) > 1024 {
		return model.AppSplashScreen{}, errors.New("splash cover is invalid")
	}
	if containsWhitespace(item.CoverURL) {
		return model.AppSplashScreen{}, errors.New("splash cover cannot contain whitespace")
	}
	if item.SplashType != "h5" && item.SplashType != "app" {
		return model.AppSplashScreen{}, errors.New("splash type is invalid")
	}
	if item.SplashType == "h5" {
		// H5 开屏必须带可直接打开的链接；APP 类型参数由客户端解释，所以只做长度限制。
		if item.Param == "" {
			return model.AppSplashScreen{}, errors.New("h5 link is required")
		}
		if err := validateH5URL(item.Param); err != nil {
			return model.AppSplashScreen{}, err
		}
	}
	if len(item.Param) > 2048 {
		return model.AppSplashScreen{}, errors.New("splash param is too long")
	}
	if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
		return model.AppSplashScreen{}, errors.New("splash status is invalid")
	}
	if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
		return model.AppSplashScreen{}, errors.New("splash delivery time is invalid")
	}
	if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
		return model.AppSplashScreen{}, errors.New("splash delivery time range is invalid")
	}
	if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
		// 管理员保存已经结束的 active 配置时直接落为 expired，列表和 App 侧都不会误判为可投放。
		item.Status = bannerStatusExpired
	}
	if item.Platform != "android" && item.Platform != "ios" {
		return model.AppSplashScreen{}, errors.New("splash platform is invalid")
	}
	if item.RegionID < 0 {
		return model.AppSplashScreen{}, errors.New("splash region is invalid")
	}
	if item.CountryCode != "" && !validCountryCode(item.CountryCode) {
		return model.AppSplashScreen{}, errors.New("splash country is invalid")
	}
	if utf8.RuneCountInString(item.Description) > 255 {
		return model.AppSplashScreen{}, errors.New("splash description is too long")
	}
	if req.DisplayDurationMs < 0 {
		return model.AppSplashScreen{}, errors.New("splash display duration is invalid")
	}
	return item, nil
}

func appSplashScreenFromModel(item model.AppSplashScreen) AppSplashScreen {
	// DTO 只返回后台页面需要的字段，不暴露数据库列名；gateway 另有 snake_case 的 App 输出结构。
	return AppSplashScreen{
		ID:                item.ID,
		AppCode:           item.AppCode,
		CoverURL:          item.CoverURL,
		SplashType:        item.SplashType,
		Param:             item.Param,
		Status:            item.Status,
		Platform:          item.Platform,
		SortOrder:         item.SortOrder,
		RegionID:          item.RegionID,
		CountryCode:       item.CountryCode,
		Description:       item.Description,
		DisplayDurationMs: item.DisplayDurationMS,
		StartsAtMs:        item.StartsAtMS,
		EndsAtMs:          item.EndsAtMS,
		CreatedAtMs:       item.CreatedAtMS,
		UpdatedAtMs:       item.UpdatedAtMS,
	}
}

func popupModelFromRequest(appCode string, req popupRequest) (model.AppPopup, error) {
	item := model.AppPopup{
		AppCode:           appctx.Normalize(appCode),
		Code:              strings.TrimSpace(req.Code),
		Name:              strings.TrimSpace(req.Name),
		ImageURL:          strings.TrimSpace(req.ImageURL),
		JumpType:          normalizeJumpType(req.JumpType),
		JumpURL:           strings.TrimSpace(req.JumpURL),
		DisplayPeriodDays: req.DisplayPeriodDays,
		Status:            normalizeBannerStatus(req.Status),
		SortOrder:         req.SortOrder,
		StartsAtMS:        req.StartsAtMs,
		EndsAtMS:          req.EndsAtMs,
	}
	if err := validateH5Key(item.Code); err != nil {
		return model.AppPopup{}, errors.New("popup code is invalid")
	}
	if item.Name == "" || utf8.RuneCountInString(item.Name) > 80 {
		return model.AppPopup{}, errors.New("popup name is invalid")
	}
	if len(item.ImageURL) > 1024 {
		return model.AppPopup{}, errors.New("popup image is too long")
	}
	if item.ImageURL != "" && containsWhitespace(item.ImageURL) {
		return model.AppPopup{}, errors.New("popup image cannot contain whitespace")
	}
	if item.JumpType != "h5" && item.JumpType != "app" {
		return model.AppPopup{}, errors.New("popup jump type is invalid")
	}
	if item.JumpURL == "" {
		return model.AppPopup{}, errors.New("popup jump url is required")
	}
	if len(item.JumpURL) > 2048 {
		return model.AppPopup{}, errors.New("popup jump url is too long")
	}
	if item.JumpType == "h5" {
		// H5 弹窗必须是无空白的可打开地址；APP 类型由客户端解释路径，但同样不允许空白字符破坏路由解析。
		if err := validateH5URL(item.JumpURL); err != nil {
			return model.AppPopup{}, err
		}
	} else if containsWhitespace(item.JumpURL) {
		return model.AppPopup{}, errors.New("popup jump url cannot contain whitespace")
	}
	if item.DisplayPeriodDays < 0 {
		return model.AppPopup{}, errors.New("popup display period is invalid")
	}
	if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
		return model.AppPopup{}, errors.New("popup status is invalid")
	}
	if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
		return model.AppPopup{}, errors.New("popup effective time is invalid")
	}
	if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
		return model.AppPopup{}, errors.New("popup effective time range is invalid")
	}
	if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
		// 已经结束的 active 弹窗保存为 expired，App 接口不会再下发，后台也能直接看到过期状态。
		item.Status = bannerStatusExpired
	}
	return item, nil
}

func appPopupFromModel(item model.AppPopup) AppPopup {
	return AppPopup{
		ID:                item.ID,
		AppCode:           item.AppCode,
		Code:              item.Code,
		Name:              item.Name,
		ImageURL:          item.ImageURL,
		JumpType:          item.JumpType,
		JumpURL:           item.JumpURL,
		DisplayPeriodDays: item.DisplayPeriodDays,
		Status:            item.Status,
		SortOrder:         item.SortOrder,
		StartsAtMs:        item.StartsAtMS,
		EndsAtMs:          item.EndsAtMS,
		CreatedAtMs:       item.CreatedAtMS,
		UpdatedAtMs:       item.UpdatedAtMS,
	}
}

func appVersionModelFromRequest(appCode string, req appVersionRequest) (model.AppVersion, error) {
	item := model.AppVersion{
		AppCode:     appctx.Normalize(appCode),
		Platform:    normalizeAppPlatform(req.Platform),
		Version:     strings.TrimSpace(req.Version),
		BuildNumber: req.BuildNumber,
		ForceUpdate: req.ForceUpdate,
		DownloadURL: strings.TrimSpace(req.DownloadURL),
		Description: strings.TrimSpace(req.Description),
	}
	if item.Platform != "android" && item.Platform != "ios" {
		return model.AppVersion{}, errors.New("app version platform is invalid")
	}
	if item.Version == "" || utf8.RuneCountInString(item.Version) > 40 {
		return model.AppVersion{}, errors.New("app version is invalid")
	}
	if item.BuildNumber <= 0 {
		return model.AppVersion{}, errors.New("app build number is invalid")
	}
	if item.DownloadURL == "" || len(item.DownloadURL) > 2048 {
		return model.AppVersion{}, errors.New("app download url is invalid")
	}
	for _, char := range item.DownloadURL {
		if unicode.IsSpace(char) {
			return model.AppVersion{}, errors.New("app download url cannot contain whitespace")
		}
	}
	if utf8.RuneCountInString(item.Description) > 1000 {
		return model.AppVersion{}, errors.New("app version description is too long")
	}
	return item, nil
}

func appVersionFromModel(item model.AppVersion) AppVersion {
	return AppVersion{
		ID:          item.ID,
		AppCode:     item.AppCode,
		Platform:    item.Platform,
		Version:     item.Version,
		BuildNumber: item.BuildNumber,
		ForceUpdate: item.ForceUpdate,
		DownloadURL: item.DownloadURL,
		Description: item.Description,
		CreatedAtMs: item.CreatedAtMS,
		UpdatedAtMs: item.UpdatedAtMS,
	}
}

func exploreTabModelFromRequest(appCode string, req exploreTabRequest) (model.AppExploreTab, error) {
	item := model.AppExploreTab{
		AppCode:   appctx.Normalize(appCode),
		Tab:       strings.TrimSpace(req.Tab),
		H5URL:     strings.TrimSpace(req.H5URL),
		Enabled:   req.Enabled,
		SortOrder: req.SortOrder,
	}
	if item.Tab == "" || utf8.RuneCountInString(item.Tab) > 40 {
		return model.AppExploreTab{}, errors.New("explore tab is invalid")
	}
	if item.H5URL == "" || len(item.H5URL) > 2048 {
		return model.AppExploreTab{}, errors.New("explore h5 url is invalid")
	}
	if err := validateH5URL(item.H5URL); err != nil {
		return model.AppExploreTab{}, err
	}
	return item, nil
}

func exploreTabFromModel(item model.AppExploreTab) ExploreTab {
	return ExploreTab{
		ID:          item.ID,
		AppCode:     item.AppCode,
		Tab:         item.Tab,
		H5URL:       item.H5URL,
		Enabled:     item.Enabled,
		SortOrder:   item.SortOrder,
		CreatedAtMs: item.CreatedAtMS,
		UpdatedAtMs: item.UpdatedAtMS,
	}
}

func normalizeBannerType(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeSplashType(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeJumpType(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeSplashDisplayDurationMS(value int) int {
	// 管理端允许 0 表示“客户端默认 3 秒”，空请求和历史数据都统一补成 3000ms，避免 App 侧拿到缺省字段后出现 0 秒闪退式开屏。
	if value == 0 {
		return splashDefaultDisplayDurationMS
	}
	return value
}

func normalizeBannerStatus(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		return bannerStatusActive
	}
	return value
}

func normalizeBannerDisplayScope(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	switch value {
	case "":
		return bannerDisplayScopeHome
	case bannerDisplayScopeHome, "首页":
		return bannerDisplayScopeHome
	case bannerDisplayScopeRoom, "房间内":
		return bannerDisplayScopeRoom
	case bannerDisplayScopeDiscover, "发现页", "发现":
		return bannerDisplayScopeDiscover
	case bannerDisplayScopeRecharge, "充值页":
		return bannerDisplayScopeRecharge
	case bannerDisplayScopeMe, "我的页", "我的":
		return bannerDisplayScopeMe
	default:
		return value
	}
}

func normalizeBannerDisplayScopes(values []string, fallback string) []string {
	seen := make(map[string]struct{}, len(bannerDisplayScopeOrder))
	for _, value := range values {
		for _, part := range strings.Split(value, ",") {
			scope := normalizeBannerDisplayScope(part)
			if scope == "" {
				continue
			}
			seen[scope] = struct{}{}
		}
	}
	if len(seen) == 0 {
		for _, part := range strings.Split(fallback, ",") {
			scope := normalizeBannerDisplayScope(part)
			if scope == "" {
				continue
			}
			seen[scope] = struct{}{}
		}
	}
	if len(seen) == 0 {
		seen[bannerDisplayScopeHome] = struct{}{}
	}
	out := make([]string, 0, len(seen))
	for _, scope := range bannerDisplayScopeOrder {
		if _, ok := seen[scope]; ok {
			out = append(out, scope)
			delete(seen, scope)
		}
	}
	for scope := range seen {
		out = append(out, scope)
	}
	return out
}

func bannerDisplayScopeList(value string) []string {
	return normalizeBannerDisplayScopes(nil, value)
}

func bannerDisplayScopeValue(scopes []string) string {
	return strings.Join(scopes, ",")
}

func validBannerDisplayScopes(scopes []string) bool {
	if len(scopes) == 0 {
		return false
	}
	for _, scope := range scopes {
		if scope != bannerDisplayScopeHome &&
			scope != bannerDisplayScopeRoom &&
			scope != bannerDisplayScopeDiscover &&
			scope != bannerDisplayScopeRecharge &&
			scope != bannerDisplayScopeMe {
			return false
		}
	}
	return true
}

func bannerDisplayScopesContain(scopes []string, target string) bool {
	for _, scope := range scopes {
		if scope == target {
			return true
		}
	}
	return false
}

func normalizeBannerPlatform(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeAppPlatform(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func normalizeCountryCode(value string) string {
	return strings.ToUpper(strings.TrimSpace(value))
}

func containsWhitespace(value string) bool {
	for _, char := range value {
		if unicode.IsSpace(char) {
			return true
		}
	}
	return false
}

func validCountryCode(value string) bool {
	if len(value) < 2 || len(value) > 3 {
		return false
	}
	for _, char := range value {
		if char < 'A' || char > 'Z' {
			return false
		}
	}
	return true
}
