// Package appconfig 读取后台管理维护的 App 运行时配置。
package appconfig

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"strconv"
	"strings"
	"sync"
	"time"

	mysqlDriver "github.com/go-sql-driver/mysql"
	"github.com/redis/go-redis/v9"
)

const h5LinkGroup = "h5-links"
const roomRegionWhitelistGroup = "room-region-whitelist"
const roomDiscoveryGroup = "room-discovery"
const roomDiscoveryScopeKey = "scope"
const roomDiscoveryScopeAllActiveRegions = "all_active_regions"

const runtimeCapabilitiesGroup = "runtime-capabilities"
const activityLeaderboardScopeKey = "activity_leaderboard_scope"
const resourceEquipCommandIDPolicyKey = "resource_equip_command_id_policy"

const (
	ActivityLeaderboardScopeAppGlobal    = "app_global"
	ActivityLeaderboardScopeViewerRegion = "viewer_region"

	ResourceEquipCommandIDPolicyLegacyOptional = "legacy_optional"
	ResourceEquipCommandIDPolicyRequired       = "required"
)

const defaultRoomDiscoveryCacheTTL = 30 * time.Second
const defaultRuntimeCapabilitiesCacheTTL = 30 * time.Second

const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND is_deleted = FALSE ORDER BY `key` ASC"
const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1"
const getRoomDiscoveryScopeSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND `key` = ? AND is_deleted = FALSE LIMIT 1"
const getRuntimeCapabilitiesSQL = "SELECT `key`, COALESCE(value, '') FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND `key` IN (?, ?) AND is_deleted = FALSE"
const bdLeaderPositionAliasSQL = `
	SELECT position_alias
	FROM admin_bd_leader_position_aliases
	WHERE app_code = ? AND user_id = ?
	LIMIT 1`
const listExploreTabsSQL = `
	SELECT id, app_code, tab, h5_url, enabled, sort_order, updated_at_ms
	FROM admin_app_explore_tabs
	WHERE app_code = ? AND enabled = TRUE
	ORDER BY sort_order DESC, id DESC`
const listAppBannersSQL = `
	SELECT id, app_code, cover_url, room_small_image_url, discover_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
	FROM admin_app_banners
	WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
	  AND (? = '' OR platform = ?)
	  AND (region_id = 0 OR region_id = ?)
	  AND (country_code = '' OR country_code = ?)
	  AND (starts_at_ms = 0 OR starts_at_ms <= ?)
	  AND (ends_at_ms = 0 OR ends_at_ms > ?)
	ORDER BY sort_order DESC, id DESC`
const listSplashScreensSQL = `
	SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, display_duration_ms, starts_at_ms, ends_at_ms, updated_at_ms
	FROM admin_app_splash_screens
	WHERE app_code = ? AND status = 'active'
	  AND (? = '' OR platform = ?)
	  AND (region_id = 0 OR region_id = ?)
	  AND (country_code = '' OR country_code = ?)
	  AND (starts_at_ms = 0 OR starts_at_ms <= ?)
	  AND (ends_at_ms = 0 OR ends_at_ms > ?)
	ORDER BY sort_order DESC, id DESC`
const listPopupsSQL = `
	SELECT id, app_code, code, name, image_url, jump_type, jump_url, display_period_days, sort_order, starts_at_ms, ends_at_ms, updated_at_ms
	FROM admin_app_popups
	WHERE app_code = ? AND status = 'active'
	  AND (starts_at_ms = 0 OR starts_at_ms <= ?)
	  AND (ends_at_ms = 0 OR ends_at_ms > ?)
	ORDER BY sort_order DESC, id DESC`
const latestAppVersionSQL = `
	SELECT id, app_code, platform, version, build_number, force_update, download_url, COALESCE(description, ''), updated_at_ms
	FROM admin_app_versions
	WHERE app_code = ? AND platform = ?
	ORDER BY build_number DESC, id DESC
	LIMIT 1`

// H5Link 是 gateway 下发给 App 的 H5 入口配置。
type H5Link struct {
	Key         string `json:"key"`
	Label       string `json:"label"`
	URL         string `json:"url"`
	UpdatedAtMs int64  `json:"updated_at_ms"`
}

// H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。
type H5LinkQuery struct {
	AppCode string
	UserID  int64
}

// ExploreTab 是后台 APP配置/Explore配置 中启用的 H5 tab。
type ExploreTab struct {
	ID          uint   `json:"id"`
	AppCode     string `json:"app_code"`
	Tab         string `json:"tab"`
	H5URL       string `json:"h5_url"`
	Enabled     bool   `json:"enabled"`
	SortOrder   int    `json:"sort_order"`
	UpdatedAtMs int64  `json:"updated_at_ms"`
}

// BannerQuery 是 App banner 的公开筛选条件。
type BannerQuery struct {
	AppCode      string
	DisplayScope string
	Platform     string
	RegionID     int64
	Country      string
}

// Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。
type Banner struct {
	ID                uint     `json:"id"`
	CoverURL          string   `json:"cover_url"`
	RoomSmallImageURL string   `json:"room_small_image_url,omitempty"`
	DiscoverImageURL  string   `json:"discover_image_url,omitempty"`
	BannerType        string   `json:"type"`
	DisplayScope      string   `json:"display_scope"`
	DisplayScopes     []string `json:"display_scopes,omitempty"`
	Param             string   `json:"param"`
	Platform          string   `json:"platform"`
	SortOrder         int      `json:"sort_order"`
	RegionID          int64    `json:"region_id,omitempty"`
	CountryCode       string   `json:"country_code,omitempty"`
	Description       string   `json:"description,omitempty"`
	StartsAtMs        int64    `json:"starts_at_ms,omitempty"`
	EndsAtMs          int64    `json:"ends_at_ms,omitempty"`
	UpdatedAtMs       int64    `json:"updated_at_ms"`
}

// SplashScreenQuery 是 App 开屏配置的公开筛选条件。
type SplashScreenQuery struct {
	AppCode  string
	Platform string
	RegionID int64
	Country  string
}

// SplashScreen 是 gateway 下发给 App 的开屏配置；type 语义和 banner 保持一致，h5 参数是链接，app 参数由客户端解释。
type SplashScreen struct {
	ID                uint   `json:"id"`
	CoverURL          string `json:"cover_url"`
	SplashType        string `json:"type"`
	Param             string `json:"param"`
	Platform          string `json:"platform"`
	SortOrder         int    `json:"sort_order"`
	RegionID          int64  `json:"region_id"`
	CountryCode       string `json:"country_code"`
	Description       string `json:"description"`
	DisplayDurationMs int    `json:"display_duration_ms"`
	StartsAtMs        int64  `json:"starts_at_ms"`
	EndsAtMs          int64  `json:"ends_at_ms"`
	UpdatedAtMs       int64  `json:"updated_at_ms"`
}

// PopupQuery 是 App 弹窗配置的公开筛选条件。
type PopupQuery struct {
	AppCode string
}

// Popup 是 gateway 下发给 App 的弹窗配置；display_period_days=0 表示客户端每次打开都展示。
type Popup struct {
	ID                uint   `json:"id"`
	Code              string `json:"code"`
	Name              string `json:"name"`
	ImageURL          string `json:"image_url"`
	JumpType          string `json:"jump_type"`
	JumpURL           string `json:"jump_url"`
	DisplayPeriodDays int    `json:"display_period_days"`
	SortOrder         int    `json:"sort_order"`
	StartsAtMs        int64  `json:"starts_at_ms"`
	EndsAtMs          int64  `json:"ends_at_ms"`
	UpdatedAtMs       int64  `json:"updated_at_ms"`
}

// VersionQuery 是 App 检查更新的公开筛选条件。
type VersionQuery struct {
	AppCode            string
	Platform           string
	CurrentBuildNumber int64
}

// Version 是后台 APP配置/版本管理 中当前平台的最新 App 版本。
type Version struct {
	ID          uint   `json:"id"`
	AppCode     string `json:"app_code"`
	Platform    string `json:"platform"`
	Version     string `json:"version"`
	BuildNumber int64  `json:"build_number"`
	ForceUpdate bool   `json:"force_update"`
	DownloadURL string `json:"download_url"`
	Description string `json:"description"`
	UpdatedAtMs int64  `json:"updated_at_ms"`
}

// RuntimeCapabilities 是后台按 App 保存的运行时行为选择；缺少单项时使用通用兼容默认值。
type RuntimeCapabilities struct {
	ActivityLeaderboardScope     string
	ResourceEquipCommandIDPolicy string
}

// RuntimeCapabilityReader 把活动榜单和资源佩戴统一收敛到同一次 App 配置读取。
type RuntimeCapabilityReader interface {
	GetRuntimeCapabilities(ctx context.Context, appCode string) (RuntimeCapabilities, error)
}

// Reader 是 HTTP 层读取 H5 配置的最小依赖。
type Reader interface {
	ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error)
	ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error)
	ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
	ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error)
	ListPopups(ctx context.Context, query PopupQuery) ([]Popup, error)
	LatestVersion(ctx context.Context, query VersionQuery) (Version, error)
	RoomRegionWhitelistAllows(ctx context.Context, appCode string, userID int64) (bool, error)
}

// MySQLReader 从 hyapp_admin 读取后台 App 配置。
type MySQLReader struct {
	db                       *sql.DB
	cache                    *redis.Client
	cachePrefix              string
	cacheTTL                 time.Duration
	roomDiscoveryMu          sync.Mutex
	roomDiscoveryCache       map[string]roomDiscoveryCacheEntry
	roomDiscoveryCacheTTL    time.Duration
	runtimeCapabilitiesMu    sync.Mutex
	runtimeCapabilitiesCache map[string]runtimeCapabilitiesCacheEntry
}

type roomDiscoveryCacheEntry struct {
	allActiveRegions bool
	expiresAt        time.Time
}

type runtimeCapabilitiesCacheEntry struct {
	capabilities RuntimeCapabilities
	expiresAt    time.Time
}

// OpenMySQLReader 创建后台配置只读连接池。
func OpenMySQLReader(dsn string) (*MySQLReader, error) {
	db, err := sql.Open("mysql", strings.TrimSpace(dsn))
	if err != nil {
		return nil, err
	}

	return &MySQLReader{
		db:                       db,
		roomDiscoveryCache:       make(map[string]roomDiscoveryCacheEntry),
		roomDiscoveryCacheTTL:    defaultRoomDiscoveryCacheTTL,
		runtimeCapabilitiesCache: make(map[string]runtimeCapabilitiesCacheEntry),
	}, nil
}

// SetRoomDiscoveryCacheTTL 明确后台更新的最长可见窗口。缓存仅保存一个 App 的枚举解析结果，过期后再查 admin DB。
func (r *MySQLReader) SetRoomDiscoveryCacheTTL(ttl time.Duration) {
	if r == nil {
		return
	}
	if ttl <= 0 {
		ttl = defaultRoomDiscoveryCacheTTL
	}
	r.roomDiscoveryMu.Lock()
	r.roomDiscoveryCacheTTL = ttl
	// TTL 变更通常发生在启动注入期；清空旧项可保证运行期重配不会沿用更长的旧过期时间。
	r.roomDiscoveryCache = make(map[string]roomDiscoveryCacheEntry)
	r.roomDiscoveryMu.Unlock()
}

// Close 关闭后台配置连接池。
func (r *MySQLReader) Close() error {
	if r == nil || r.db == nil {
		return nil
	}

	return errors.Join(r.db.Close(), r.closeCache())
}

func (r *MySQLReader) closeCache() error {
	if r == nil || r.cache == nil {
		return nil
	}
	return r.cache.Close()
}

// OpenRedisCache 创建 App 配置 Redis 缓存连接；启动期探测失败直接暴露配置问题。
func OpenRedisCache(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
	client := redis.NewClient(&redis.Options{
		Addr:     strings.TrimSpace(addr),
		Password: password,
		DB:       db,
	})
	if err := client.Ping(ctx).Err(); err != nil {
		_ = client.Close()
		return nil, err
	}
	return client, nil
}

// SetRedisCache 给低频后台配置挂 Redis 五分钟读缓存，避免房间列表每次都查 hyapp_admin。
func (r *MySQLReader) SetRedisCache(client *redis.Client, keyPrefix string, ttl time.Duration) {
	if r == nil {
		return
	}
	r.cache = client
	r.cachePrefix = strings.TrimSpace(keyPrefix)
	if r.cachePrefix == "" {
		r.cachePrefix = "gateway:app_config:"
	}
	if ttl <= 0 {
		ttl = 5 * time.Minute
	}
	r.cacheTTL = ttl
}

// ListH5Links 返回后台动态维护的 H5 入口集合。
func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error) {
	if r == nil || r.db == nil {
		return nil, errors.New("app config reader is not configured")
	}

	appCode := normalizeAppCode(query.AppCode)
	// H5 链接是 App 专属运营内容；未配置的 App 返回空列表，不能回退到空 app_code 历史基线。
	rows, err := r.db.QueryContext(ctx, listH5LinksSQL, appCode, h5LinkGroup)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]H5Link, 0)
	for rows.Next() {
		var item H5Link
		if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil {
			return nil, err
		}
		item.Key = strings.TrimSpace(item.Key)
		item.Label = strings.TrimSpace(item.Label)
		if item.Label == "" {
			item.Label = item.Key
		}
		item.URL = strings.TrimSpace(item.URL)
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil {
		return nil, err
	} else if alias != "" {
		applyAdminH5LinkAlias(items, alias)
	}
	return items, nil
}

func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) {
	if query.UserID <= 0 {
		return "", nil
	}
	var alias string
	err := r.db.QueryRowContext(ctx, bdLeaderPositionAliasSQL, normalizeAppCode(query.AppCode), query.UserID).Scan(&alias)
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	if isMissingTableError(err) {
		// gateway 读的是 admin 库；迁移未发布前保持 H5 配置原始返回，不让可选别名阻断老环境。
		return "", nil
	}
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(alias), nil
}

// ListExploreTabs 返回当前 App 启用的 Explore H5 tabs；后台关闭的 tab 不下发给客户端。
func (r *MySQLReader) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) {
	if r == nil || r.db == nil {
		return nil, errors.New("app config reader is not configured")
	}

	rows, err := r.db.QueryContext(ctx, listExploreTabsSQL, normalizeAppCode(appCode))
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]ExploreTab, 0)
	for rows.Next() {
		var item ExploreTab
		if err := rows.Scan(&item.ID, &item.AppCode, &item.Tab, &item.H5URL, &item.Enabled, &item.SortOrder, &item.UpdatedAtMs); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

// ListBanners 返回当前 App、显示范围、平台和地域可见的 banner。
func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) {
	if r == nil || r.db == nil {
		return nil, errors.New("app config reader is not configured")
	}

	appCode := normalizeAppCode(query.AppCode)
	displayScope := NormalizeBannerDisplayScope(query.DisplayScope)
	if displayScope == "" {
		return []Banner{}, nil
	}
	platform := normalizePlatform(query.Platform)
	country := normalizeCountry(query.Country)
	nowMs := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx, listAppBannersSQL, appCode, displayScope, platform, platform, query.RegionID, country, nowMs, nowMs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]Banner, 0)
	for rows.Next() {
		var item Banner
		var appCode string
		var updatedAtMS int64
		if err := rows.Scan(
			&item.ID,
			&appCode,
			&item.CoverURL,
			&item.RoomSmallImageURL,
			&item.DiscoverImageURL,
			&item.BannerType,
			&item.DisplayScope,
			&item.Param,
			&item.Platform,
			&item.SortOrder,
			&item.RegionID,
			&item.CountryCode,
			&item.Description,
			&item.StartsAtMs,
			&item.EndsAtMs,
			&updatedAtMS,
		); err != nil {
			return nil, err
		}
		item.UpdatedAtMs = updatedAtMS
		item.DisplayScopes = bannerDisplayScopeList(item.DisplayScope)
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

// ListSplashScreens 返回当前 App、平台和地域可见的开屏配置；未限定平台时返回所有平台配置，客户端可按需自筛。
func (r *MySQLReader) ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error) {
	if r == nil || r.db == nil {
		return nil, errors.New("app config reader is not configured")
	}

	appCode := normalizeAppCode(query.AppCode)
	platform := normalizePlatform(query.Platform)
	country := normalizeCountry(query.Country)
	nowMs := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx, listSplashScreensSQL, appCode, platform, platform, query.RegionID, country, nowMs, nowMs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]SplashScreen, 0)
	for rows.Next() {
		var item SplashScreen
		var appCode string
		var updatedAtMS int64
		if err := rows.Scan(
			&item.ID,
			&appCode,
			&item.CoverURL,
			&item.SplashType,
			&item.Param,
			&item.Platform,
			&item.SortOrder,
			&item.RegionID,
			&item.CountryCode,
			&item.Description,
			&item.DisplayDurationMs,
			&item.StartsAtMs,
			&item.EndsAtMs,
			&updatedAtMS,
		); err != nil {
			return nil, err
		}
		item.UpdatedAtMs = updatedAtMS
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

// ListPopups 返回当前 App 生效窗口内的弹窗配置；展示频控由客户端按 display_period_days 本地执行。
func (r *MySQLReader) ListPopups(ctx context.Context, query PopupQuery) ([]Popup, error) {
	if r == nil || r.db == nil {
		return nil, errors.New("app config reader is not configured")
	}

	appCode := normalizeAppCode(query.AppCode)
	nowMs := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx, listPopupsSQL, appCode, nowMs, nowMs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]Popup, 0)
	for rows.Next() {
		var item Popup
		var appCode string
		var updatedAtMS int64
		if err := rows.Scan(
			&item.ID,
			&appCode,
			&item.Code,
			&item.Name,
			&item.ImageURL,
			&item.JumpType,
			&item.JumpURL,
			&item.DisplayPeriodDays,
			&item.SortOrder,
			&item.StartsAtMs,
			&item.EndsAtMs,
			&updatedAtMS,
		); err != nil {
			return nil, err
		}
		item.UpdatedAtMs = updatedAtMS
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

// LatestVersion 返回指定 App 和平台的最高 build_number 版本；未配置时返回空版本，方便 App 端按无更新处理。
func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) {
	if r == nil || r.db == nil {
		return Version{}, errors.New("app config reader is not configured")
	}

	var item Version
	err := r.db.QueryRowContext(ctx, latestAppVersionSQL, normalizeAppCode(query.AppCode), normalizePlatform(query.Platform)).Scan(
		&item.ID,
		&item.AppCode,
		&item.Platform,
		&item.Version,
		&item.BuildNumber,
		&item.ForceUpdate,
		&item.DownloadURL,
		&item.Description,
		&item.UpdatedAtMs,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return Version{AppCode: normalizeAppCode(query.AppCode), Platform: normalizePlatform(query.Platform)}, nil
	}
	if err != nil {
		return Version{}, err
	}
	return item, nil
}

// RoomRegionWhitelistAllows 判断用户是否可绕过房间区域限制；结果按 app+user 缓存五分钟。
func (r *MySQLReader) RoomRegionWhitelistAllows(ctx context.Context, appCode string, userID int64) (bool, error) {
	if userID <= 0 {
		return false, nil
	}
	if r == nil || r.db == nil {
		return false, errors.New("app config reader is not configured")
	}
	app := normalizeAppCode(appCode)
	if cached, ok, err := r.cachedRoomRegionWhitelist(ctx, app, userID); err == nil && ok {
		return cached, nil
	}

	allowed, err := r.roomRegionWhitelistAllowsFromDB(ctx, app, userID)
	if err != nil {
		return false, err
	}
	r.storeRoomRegionWhitelistCache(ctx, app, userID, allowed)
	return allowed, nil
}

// RoomDiscoveryAllRegions returns the configured discovery capability for one tenant.
// Missing or invalid configuration fails closed to viewer-region scope.
func (r *MySQLReader) RoomDiscoveryAllRegions(ctx context.Context, appCode string) (bool, error) {
	if r == nil || r.db == nil {
		return false, errors.New("app config reader is not configured")
	}
	appCode = normalizeAppCode(appCode)
	now := time.Now()
	if entry, ok := r.cachedRoomDiscovery(appCode, now); ok {
		return entry.allActiveRegions, nil
	}
	var scope string
	err := r.db.QueryRowContext(ctx, getRoomDiscoveryScopeSQL, appCode, roomDiscoveryGroup, roomDiscoveryScopeKey).Scan(&scope)
	if errors.Is(err, sql.ErrNoRows) || isMissingTableError(err) {
		scope = ""
	} else if err != nil {
		return false, err
	}
	allActiveRegions := strings.EqualFold(strings.TrimSpace(scope), roomDiscoveryScopeAllActiveRegions)
	r.roomDiscoveryMu.Lock()
	ttl := r.roomDiscoveryCacheTTL
	if ttl <= 0 {
		ttl = defaultRoomDiscoveryCacheTTL
	}
	if r.roomDiscoveryCache == nil {
		r.roomDiscoveryCache = make(map[string]roomDiscoveryCacheEntry)
	}
	// 缺失或非法枚举同样短暂缓存为 viewer_region；这既 fail closed，也避免错误配置把 DB 压力放大。
	r.roomDiscoveryCache[appCode] = roomDiscoveryCacheEntry{allActiveRegions: allActiveRegions, expiresAt: now.Add(ttl)}
	r.roomDiscoveryMu.Unlock()
	return allActiveRegions, nil
}

// GetRuntimeCapabilities 在短 TTL 内复用每个 App 的配置；缓存过期后的并发请求允许各自查询，
// 避免为极短查询维护不会回收的逐 App 锁。查询命中 (app_code, group) 索引且最多返回两行。
func (r *MySQLReader) GetRuntimeCapabilities(ctx context.Context, appCode string) (RuntimeCapabilities, error) {
	defaults := defaultRuntimeCapabilities()
	if r == nil || r.db == nil {
		return defaults, errors.New("app config reader is not configured")
	}
	appCode = normalizeAppCode(appCode)
	now := time.Now()
	if entry, ok := r.cachedRuntimeCapabilities(appCode, now); ok {
		return entry.capabilities, nil
	}
	capabilities, err := r.runtimeCapabilitiesFromDB(ctx, appCode)
	if err != nil {
		return defaults, err
	}
	r.storeRuntimeCapabilitiesCache(appCode, capabilities, now.Add(defaultRuntimeCapabilitiesCacheTTL))
	return capabilities, nil
}

func (r *MySQLReader) runtimeCapabilitiesFromDB(ctx context.Context, appCode string) (RuntimeCapabilities, error) {
	capabilities := defaultRuntimeCapabilities()
	rows, err := r.db.QueryContext(ctx, getRuntimeCapabilitiesSQL, appCode, runtimeCapabilitiesGroup, activityLeaderboardScopeKey, resourceEquipCommandIDPolicyKey)
	if isMissingTableError(err) {
		return capabilities, nil
	}
	if err != nil {
		return RuntimeCapabilities{}, err
	}
	defer rows.Close()

	for rows.Next() {
		var key string
		var value string
		if err := rows.Scan(&key, &value); err != nil {
			return RuntimeCapabilities{}, err
		}
		value = strings.ToLower(strings.TrimSpace(value))
		switch strings.TrimSpace(key) {
		case activityLeaderboardScopeKey:
			if value != ActivityLeaderboardScopeAppGlobal && value != ActivityLeaderboardScopeViewerRegion {
				return RuntimeCapabilities{}, errors.New("invalid activity leaderboard scope")
			}
			capabilities.ActivityLeaderboardScope = value
		case resourceEquipCommandIDPolicyKey:
			if value != ResourceEquipCommandIDPolicyLegacyOptional && value != ResourceEquipCommandIDPolicyRequired {
				return RuntimeCapabilities{}, errors.New("invalid resource equip command id policy")
			}
			capabilities.ResourceEquipCommandIDPolicy = value
		}
	}
	if err := rows.Err(); err != nil {
		return RuntimeCapabilities{}, err
	}
	return capabilities, nil
}

func defaultRuntimeCapabilities() RuntimeCapabilities {
	return RuntimeCapabilities{
		ActivityLeaderboardScope:     ActivityLeaderboardScopeAppGlobal,
		ResourceEquipCommandIDPolicy: ResourceEquipCommandIDPolicyLegacyOptional,
	}
}

func (r *MySQLReader) cachedRuntimeCapabilities(appCode string, now time.Time) (runtimeCapabilitiesCacheEntry, bool) {
	r.runtimeCapabilitiesMu.Lock()
	defer r.runtimeCapabilitiesMu.Unlock()
	entry, ok := r.runtimeCapabilitiesCache[appCode]
	return entry, ok && now.Before(entry.expiresAt)
}

func (r *MySQLReader) storeRuntimeCapabilitiesCache(appCode string, capabilities RuntimeCapabilities, expiresAt time.Time) {
	r.runtimeCapabilitiesMu.Lock()
	defer r.runtimeCapabilitiesMu.Unlock()
	if r.runtimeCapabilitiesCache == nil {
		r.runtimeCapabilitiesCache = make(map[string]runtimeCapabilitiesCacheEntry)
	}
	r.runtimeCapabilitiesCache[appCode] = runtimeCapabilitiesCacheEntry{capabilities: capabilities, expiresAt: expiresAt}
}

func (r *MySQLReader) cachedRoomDiscovery(appCode string, now time.Time) (roomDiscoveryCacheEntry, bool) {
	r.roomDiscoveryMu.Lock()
	defer r.roomDiscoveryMu.Unlock()
	entry, ok := r.roomDiscoveryCache[appCode]
	return entry, ok && now.Before(entry.expiresAt)
}

func (r *MySQLReader) roomRegionWhitelistAllowsFromDB(ctx context.Context, appCode string, userID int64) (bool, error) {
	var raw string
	err := r.db.QueryRowContext(ctx, getRoomRegionWhitelistSQL, roomRegionWhitelistGroup, appCode).Scan(&raw)
	if errors.Is(err, sql.ErrNoRows) || isMissingTableError(err) {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	ids, err := parseRoomRegionWhitelist(raw)
	if err != nil {
		return false, err
	}
	_, ok := ids[userID]
	return ok, nil
}

func (r *MySQLReader) cachedRoomRegionWhitelist(ctx context.Context, appCode string, userID int64) (bool, bool, error) {
	if r == nil || r.cache == nil {
		return false, false, nil
	}
	value, err := r.cache.Get(ctx, r.roomRegionWhitelistCacheKey(appCode, userID)).Result()
	if errors.Is(err, redis.Nil) {
		return false, false, nil
	}
	if err != nil {
		return false, false, err
	}
	return value == "1", true, nil
}

func (r *MySQLReader) storeRoomRegionWhitelistCache(ctx context.Context, appCode string, userID int64, allowed bool) {
	if r == nil || r.cache == nil {
		return
	}
	value := "0"
	if allowed {
		value = "1"
	}
	// 缓存只优化读路径；写入失败时保持 DB 判定结果，不让 Redis 抖动阻断房间列表。
	_ = r.cache.Set(ctx, r.roomRegionWhitelistCacheKey(appCode, userID), value, r.cacheTTL).Err()
}

func (r *MySQLReader) roomRegionWhitelistCacheKey(appCode string, userID int64) string {
	return strings.TrimRight(r.cachePrefix, ":") + ":room_region_whitelist:" + normalizeAppCode(appCode) + ":" + strconv.FormatInt(userID, 10)
}

type roomRegionWhitelistPayload struct {
	UserIDs []string `json:"user_ids"`
}

func parseRoomRegionWhitelist(raw string) (map[int64]struct{}, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return map[int64]struct{}{}, nil
	}
	var payload roomRegionWhitelistPayload
	if strings.HasPrefix(raw, "[") {
		if err := json.Unmarshal([]byte(raw), &payload.UserIDs); err != nil {
			return nil, err
		}
	} else if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return nil, err
	}
	out := make(map[int64]struct{}, len(payload.UserIDs))
	for _, item := range payload.UserIDs {
		id, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64)
		if err != nil || id <= 0 {
			continue
		}
		out[id] = struct{}{}
	}
	return out, nil
}

// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
type StaticReader struct {
	Links                    []H5Link
	ExploreTabs              []ExploreTab
	Banners                  []Banner
	SplashScreens            []SplashScreen
	Popups                   []Popup
	Version                  Version
	Err                      error
	PositionAliases          map[string]string
	RoomWhitelist            map[string]bool
	RoomDiscoveryAll         map[string]bool
	RuntimeCapabilitiesByApp map[string]RuntimeCapabilities
}

// ListH5Links 返回预置 H5 配置。
func (r StaticReader) ListH5Links(_ context.Context, query H5LinkQuery) ([]H5Link, error) {
	if r.Err != nil {
		return nil, r.Err
	}

	out := make([]H5Link, len(r.Links))
	copy(out, r.Links)
	if alias := strings.TrimSpace(r.PositionAliases[bdLeaderPositionAliasKey(query.AppCode, query.UserID)]); alias != "" {
		applyAdminH5LinkAlias(out, alias)
	}
	return out, nil
}

// ListExploreTabs 返回预置 Explore tab 配置。
func (r StaticReader) ListExploreTabs(context.Context, string) ([]ExploreTab, error) {
	if r.Err != nil {
		return nil, r.Err
	}

	out := make([]ExploreTab, len(r.ExploreTabs))
	copy(out, r.ExploreTabs)
	return out, nil
}

// ListBanners 返回预置 App banner 配置。
func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) {
	if r.Err != nil {
		return nil, r.Err
	}

	out := make([]Banner, len(r.Banners))
	copy(out, r.Banners)
	for index := range out {
		if len(out[index].DisplayScopes) == 0 {
			out[index].DisplayScopes = bannerDisplayScopeList(out[index].DisplayScope)
		}
	}
	return out, nil
}

// ListSplashScreens 返回预置开屏配置，供 HTTP 单测和无 MySQL 场景复用同一 Reader 接口。
func (r StaticReader) ListSplashScreens(context.Context, SplashScreenQuery) ([]SplashScreen, error) {
	if r.Err != nil {
		return nil, r.Err
	}

	out := make([]SplashScreen, len(r.SplashScreens))
	copy(out, r.SplashScreens)
	return out, nil
}

// ListPopups 返回预置弹窗配置，供 HTTP 单测和无 MySQL 场景复用同一 Reader 接口。
func (r StaticReader) ListPopups(context.Context, PopupQuery) ([]Popup, error) {
	if r.Err != nil {
		return nil, r.Err
	}

	out := make([]Popup, len(r.Popups))
	copy(out, r.Popups)
	return out, nil
}

// LatestVersion 返回预置版本配置。
func (r StaticReader) LatestVersion(_ context.Context, query VersionQuery) (Version, error) {
	if r.Err != nil {
		return Version{}, r.Err
	}
	item := r.Version
	if item.AppCode == "" {
		item.AppCode = normalizeAppCode(query.AppCode)
	}
	if item.Platform == "" {
		item.Platform = normalizePlatform(query.Platform)
	}
	return item, nil
}

// RoomRegionWhitelistAllows 返回内存白名单结果，测试可用 "app:user_id" 作为 key。
func (r StaticReader) RoomRegionWhitelistAllows(_ context.Context, appCode string, userID int64) (bool, error) {
	if r.Err != nil {
		return false, r.Err
	}
	return r.RoomWhitelist[bdLeaderPositionAliasKey(appCode, userID)], nil
}

// RoomDiscoveryAllRegions returns the preconfigured tenant capability in tests and local tools.
func (r StaticReader) RoomDiscoveryAllRegions(_ context.Context, appCode string) (bool, error) {
	if r.Err != nil {
		return false, r.Err
	}
	return r.RoomDiscoveryAll[normalizeAppCode(appCode)], nil
}

// GetRuntimeCapabilities 返回测试预置的 App 能力；未预置时与生产缺行行为一致使用通用默认值。
func (r StaticReader) GetRuntimeCapabilities(_ context.Context, appCode string) (RuntimeCapabilities, error) {
	if r.Err != nil {
		return RuntimeCapabilities{}, r.Err
	}
	capabilities, ok := r.RuntimeCapabilitiesByApp[normalizeAppCode(appCode)]
	if !ok {
		return defaultRuntimeCapabilities(), nil
	}
	return capabilities, nil
}

func normalizeAppCode(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		return "lalu"
	}
	return value
}

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

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

func applyAdminH5LinkAlias(items []H5Link, alias string) {
	alias = strings.TrimSpace(alias)
	if alias == "" {
		return
	}
	for index := range items {
		if strings.EqualFold(strings.TrimSpace(items[index].Key), "admin") {
			// admin 入口只覆盖展示名，key 和 URL 仍来自后台 H5 配置，保证 Flutter 路由识别不变。
			items[index].Label = alias
		}
	}
}

func bdLeaderPositionAliasKey(appCode string, userID int64) string {
	return normalizeAppCode(appCode) + ":" + strconv.FormatInt(userID, 10)
}

func isMissingTableError(err error) bool {
	var mysqlErr *mysqlDriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146
}

// NormalizeBannerDisplayScope 统一 App 和后台约定的显示范围枚举。
func NormalizeBannerDisplayScope(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	switch value {
	case "":
		return "home"
	case "home", "首页":
		return "home"
	case "room", "房间内":
		return "room"
	case "discover", "发现页", "发现":
		return "discover"
	case "recharge", "充值页":
		return "recharge"
	case "me", "我的页", "我的":
		return "me"
	default:
		return ""
	}
}

func bannerDisplayScopeList(value string) []string {
	seen := make(map[string]struct{}, 5)
	for _, part := range strings.Split(value, ",") {
		scope := NormalizeBannerDisplayScope(part)
		if scope != "" {
			seen[scope] = struct{}{}
		}
	}
	out := make([]string, 0, len(seen))
	for _, scope := range []string{"home", "room", "discover", "recharge", "me"} {
		if _, ok := seen[scope]; ok {
			out = append(out, scope)
		}
	}
	return out
}
