package service

import (
	"encoding/json"
	"errors"
	"net"
	"net/url"
	"strconv"
	"strings"
	"sync"

	"git.iioio.com/freefire/jiang13-forum/model"
)

// 论坛设置键名
const (
	SettingPostEditWindowHours      = "post_edit_window_hours"
	SettingCommentEditWindowMinutes = "comment_edit_window_minutes"

	SettingRateLimitPost     = "rate_limit_post"
	SettingRateLimitComment  = "rate_limit_comment"
	SettingRateLimitRegister = "rate_limit_register"
	SettingRateLimitLogin    = "rate_limit_login"
	SettingRateLimitWindow   = "rate_limit_window_sec"

	SettingPostTitleMax   = "post_title_max"
	SettingPostTagsMax    = "post_tags_max"
	SettingPostContentMax = "post_content_max"

	SettingCommentMax = "comment_max"

	SettingSearchKeywordMin = "search_keyword_min"
	SettingSearchKeywordMax = "search_keyword_max"

	SettingPageSizeDefault = "page_size_default"

	SettingPasswordMinLen = "password_min_len"
	SettingAvatarMaxMB    = "avatar_max_mb"
	SettingSignatureMax   = "signature_max"

	SettingOpenPostsInNewTab        = "open_posts_in_new_tab"
	SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
	SettingAsideShowTagCloud        = "aside_show_tag_cloud"
	SettingAsideShowRecentComments  = "aside_show_recent_comments"
	SettingAsideShowFriendLinks     = "aside_show_friend_links"
	SettingAsideShowShowcase        = "aside_show_showcase"
	SettingAsideWidgets             = "aside_widgets"
	SettingNavShowFriendLinks       = "nav_show_friend_links"
	SettingFooterShowFriendLinks    = "footer_show_friend_links"
	SettingNavShowShowcase          = "nav_show_showcase"
	SettingFooterShowShowcase       = "footer_show_showcase"
	SettingFeedListStyle            = "feed_list_style"
	SettingFeedSortTabs             = "feed_sort_tabs"

	// 伪静态键名见 permalink.go：SettingPermalinkEnabled / SettingPermalinkExt

	SettingSMTPEnabled    = "smtp_enabled"
	SettingSMTPHost       = "smtp_host"
	SettingSMTPPort       = "smtp_port"
	SettingSMTPUsername   = "smtp_username"
	SettingSMTPPassword   = "smtp_password"
	SettingSMTPFrom       = "smtp_from"
	SettingSMTPFromName   = "smtp_from_name"
	SettingSMTPEncryption = "smtp_encryption"

	SettingOIDCEnabled    = "oidc_enabled"
	SettingOIDCRootURL    = "oidc_root_url"
	SettingOIDCGroupClaim = "oidc_group_claim"
	SettingOIDCAdminGroup = "oidc_admin_group"
	SettingOIDCUserGroup  = "oidc_user_group"

	SettingGiteaSyncEnabled     = "gitea_sync_enabled"
	SettingGiteaBaseURL         = "gitea_base_url"
	SettingGiteaToken           = "gitea_token"
	SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"

	SettingStorageType           = "storage_type"
	SettingStorageEndpoint       = "storage_endpoint"
	SettingStorageRegion         = "storage_region"
	SettingStorageBucket         = "storage_bucket"
	SettingStorageAccessKey      = "storage_access_key"
	SettingStorageSecretKey      = "storage_secret_key"
	SettingStoragePublicBaseURL  = "storage_public_base_url"
	SettingStoragePrefix         = "storage_prefix"
	SettingStorageForcePathStyle = "storage_force_path_style"
	SettingStorageImageDelivery  = "storage_image_delivery"

	SettingSiteName                  = "site_name"
	SettingSiteSlogan                = "site_slogan"
	SettingSiteDescription           = "site_description"
	SettingSiteKeywords              = "site_keywords"
	SettingSiteLogoMark              = "site_logo_mark"
	SettingSiteLogo                  = "site_logo"
	SettingSiteFavicon               = "site_favicon"
	SettingSiteOGImage               = "site_og_image"
	SettingSiteICPBeian              = "site_icp_beian"
	SettingSiteICPBeianURL           = "site_icp_beian_url"
	SettingSiteFriendLinks           = "site_friend_links"
	SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"

	SettingCommunityReportEnabled = "community_report_enabled"
	SettingCommunityHubEnabled    = "community_hub_enabled" // 遗留键，不再作为开关来源
	SettingCommunityInstanceID    = "community_instance_id"
	SettingCommunityHubURL        = "community_hub_url"
	SettingCommunitySiteURL       = "community_site_url" // 上报用的本站公开地址（可回退 OIDC ROOT_URL）

	SettingMonitorEnabled           = "monitor_enabled"
	SettingMonitorRetention         = "monitor_retention_days"
	SettingMonitorAccessLogRetention = "monitor_access_log_retention_days"
	SettingMonitorExclude           = "monitor_exclude_json"
	SettingMonitorTrustProxy        = "monitor_trust_proxy"

	// DefaultCommunityHubURL 官方演示站（社区枢纽默认地址）
	DefaultCommunityHubURL = "https://bbs.iioio.com"

	// DefaultMonitorExcludeJSON 默认排除路径/后缀（JSON 数组）
	DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]`

	// pageSizeAPIMax 单次列表请求条数硬上限（防客户端传超大 size），非后台可配项
	pageSizeAPIMax = 100
)

// ForumLimits 论坛可配置限制（API 传输结构）
type ForumLimits struct {
	PostEditWindowHours      int `json:"post_edit_window_hours"`
	CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`

	RateLimitPost      int `json:"rate_limit_post"`
	RateLimitComment   int `json:"rate_limit_comment"`
	RateLimitRegister  int `json:"rate_limit_register"`
	RateLimitLogin     int `json:"rate_limit_login"`
	RateLimitWindowSec int `json:"rate_limit_window_sec"`

	PostTitleMax   int `json:"post_title_max"`
	PostTagsMax    int `json:"post_tags_max"`
	PostContentMax int `json:"post_content_max"`

	CommentMax int `json:"comment_max"`

	SearchKeywordMin int `json:"search_keyword_min"`
	SearchKeywordMax int `json:"search_keyword_max"`

	PageSizeDefault int `json:"page_size_default"`

	PasswordMinLen int `json:"password_min_len"`
	AvatarMaxMB    int `json:"avatar_max_mb"`
	SignatureMax   int `json:"signature_max"`

	OpenPostsInNewTab        bool `json:"open_posts_in_new_tab"`
	OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`

	AsideShowTagCloud       bool          `json:"aside_show_tag_cloud"`
	AsideShowRecentComments bool          `json:"aside_show_recent_comments"`
	AsideShowFriendLinks    bool          `json:"aside_show_friend_links"`
	AsideShowShowcase       bool          `json:"aside_show_showcase"`
	AsideWidgets            []AsideWidget `json:"aside_widgets"`
	NavShowFriendLinks      bool          `json:"nav_show_friend_links"`
	FooterShowFriendLinks   bool          `json:"footer_show_friend_links"`
	NavShowShowcase         bool          `json:"nav_show_showcase"`
	FooterShowShowcase      bool          `json:"footer_show_showcase"`

	FeedListStyle string       `json:"feed_list_style"`
	FeedSortTabs  []FeedSortTab `json:"feed_sort_tabs"`

	PermalinkEnabled bool   `json:"permalink_enabled"`
	PermalinkExt     string `json:"permalink_ext"`
}

// AsideWidget 右侧栏可选组件
type AsideWidget struct {
	ID      string `json:"id"`
	Enabled bool   `json:"enabled"`
}

// FeedSortTab 首页 Feed 排序标签（名称 / 顺序 / 启停）
type FeedSortTab struct {
	ID      string `json:"id"`      // reply | latest | hot
	Label   string `json:"label"`   // 展示名
	Enabled bool   `json:"enabled"`
}

const (
	AsideWidgetTagCloud       = "tag_cloud"
	AsideWidgetRecentComments = "recent_comments"
	AsideWidgetRecentUsers    = "recent_users"
	AsideWidgetFriendLinks    = "friend_links"
	AsideWidgetShowcase       = "showcase"

	FeedSortReply  = "reply"
	FeedSortLatest = "latest"
	FeedSortHot    = "hot" // 仅推荐帖（featured）
)

var asideWidgetDefaultOrder = []string{
	AsideWidgetTagCloud,
	AsideWidgetRecentComments,
	AsideWidgetRecentUsers,
	AsideWidgetFriendLinks,
	AsideWidgetShowcase,
}

var feedSortTabDefaultOrder = []string{
	FeedSortReply,
	FeedSortLatest,
	FeedSortHot,
}

var feedSortTabDefaultLabels = map[string]string{
	FeedSortReply:  "最新",
	FeedSortLatest: "新帖子",
	FeedSortHot:    "推荐帖",
}

// ForumLimitsPublic 前台可见的限制（不含限流等内部配置）
type ForumLimitsPublic struct {
	PostTitleMax     int `json:"post_title_max"`
	PostTagsMax      int `json:"post_tags_max"`
	PostContentMax   int `json:"post_content_max"`
	CommentMax       int `json:"comment_max"`
	SearchKeywordMin int `json:"search_keyword_min"`
	SearchKeywordMax int `json:"search_keyword_max"`
	PageSizeDefault  int `json:"page_size_default"`
	PasswordMinLen   int `json:"password_min_len"`
	AvatarMaxMB      int `json:"avatar_max_mb"`
	SignatureMax     int `json:"signature_max"`

	CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`

	OpenPostsInNewTab        bool `json:"open_posts_in_new_tab"`
	OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`

	AsideShowTagCloud       bool          `json:"aside_show_tag_cloud"`
	AsideShowRecentComments bool          `json:"aside_show_recent_comments"`
	AsideShowFriendLinks    bool          `json:"aside_show_friend_links"`
	AsideShowShowcase       bool          `json:"aside_show_showcase"`
	AsideWidgets            []AsideWidget `json:"aside_widgets"`
	NavShowFriendLinks      bool          `json:"nav_show_friend_links"`
	FooterShowFriendLinks   bool          `json:"footer_show_friend_links"`
	NavShowShowcase         bool          `json:"nav_show_showcase"`
	FooterShowShowcase      bool          `json:"footer_show_showcase"`

	FeedListStyle string        `json:"feed_list_style"`
	FeedSortTabs  []FeedSortTab `json:"feed_sort_tabs"`

	PermalinkEnabled bool   `json:"permalink_enabled"`
	PermalinkExt     string `json:"permalink_ext"`

	// MonitorPageview 是否接受前台路由 pageview 信标（与 monitor_enabled 同步）
	MonitorPageview bool `json:"monitor_pageview"`
}

type settingDef struct {
	key        string
	defaultVal string
	min        int
	max        int // 0 表示不限制上限
}

var forumSettingDefs = []settingDef{
	{SettingPostEditWindowHours, "24", 0, 0},
	{SettingCommentEditWindowMinutes, "3", 0, 0},

	{SettingRateLimitPost, "10", 1, 1000},
	{SettingRateLimitComment, "10", 1, 1000},
	{SettingRateLimitRegister, "10", 1, 1000},
	{SettingRateLimitLogin, "10", 1, 1000},
	{SettingRateLimitWindow, "60", 10, 3600},

	{SettingPostTitleMax, "128", 1, 512},
	{SettingPostTagsMax, "256", 0, 512},
	{SettingPostContentMax, "50000", 0, 0},

	{SettingCommentMax, "5000", 1, 50000},

	{SettingSearchKeywordMin, "1", 0, 100},
	{SettingSearchKeywordMax, "50", 1, 200},

	{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},

	{SettingPasswordMinLen, "6", 4, 128},
	{SettingAvatarMaxMB, "2", 1, 20},
	{SettingSignatureMax, "200", 0, 512},

	{SettingOpenPostsInNewTab, "1", 0, 1},
	{SettingOpenContentLinksInNewTab, "1", 0, 1},
}

var feedSettingDefaults = map[string]string{
	SettingFeedListStyle: "title",
	SettingFeedSortTabs:  `[{"id":"reply","label":"最新","enabled":true},{"id":"latest","label":"新帖子","enabled":true},{"id":"hot","label":"推荐帖","enabled":true}]`,
}

var asideSettingDefaults = map[string]string{
	SettingAsideShowTagCloud:       "0",
	SettingAsideShowRecentComments: "0",
	SettingAsideShowFriendLinks:    "1",
	SettingAsideShowShowcase:       "0",
	SettingAsideWidgets:            `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`,
}

var mailSettingDefaults = map[string]string{
	SettingSMTPEnabled:    "0",
	SettingSMTPHost:       "",
	SettingSMTPPort:       "465",
	SettingSMTPUsername:   "",
	SettingSMTPPassword:   "",
	SettingSMTPFrom:       "",
	SettingSMTPFromName:   "姜十三论坛",
	SettingSMTPEncryption: "ssl",
}

var oidcSettingDefaults = map[string]string{
	SettingOIDCEnabled:    "0",
	SettingOIDCRootURL:    "",
	SettingOIDCGroupClaim: "groups",
	SettingOIDCAdminGroup: "gitea-admin",
	SettingOIDCUserGroup:  "gitea-users",
}

var giteaSettingDefaults = map[string]string{
	SettingGiteaSyncEnabled:     "0",
	SettingGiteaBaseURL:         "",
	SettingGiteaToken:           "",
	SettingGiteaSyncIntervalMin: "60",
}

var storageSettingDefaults = map[string]string{
	SettingStorageType:           "local",
	SettingStorageEndpoint:       "",
	SettingStorageRegion:         "us-east-1",
	SettingStorageBucket:         "",
	SettingStorageAccessKey:      "",
	SettingStorageSecretKey:      "",
	SettingStoragePublicBaseURL:  "",
	SettingStoragePrefix:         "",
	SettingStorageForcePathStyle: "1",
	SettingStorageImageDelivery:  ImageDeliveryWebP,
}

var friendLinkSettingDefaults = map[string]string{
	SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
	SettingNavShowFriendLinks:        "1",
	SettingFooterShowFriendLinks:     "1",
	SettingNavShowShowcase:           "0",
	SettingFooterShowShowcase:        "0",
}

var communitySettingDefaults = map[string]string{
	SettingCommunityReportEnabled: "0",
	SettingCommunityHubEnabled:    "0",
	SettingCommunityInstanceID:    "",
	SettingCommunityHubURL:        DefaultCommunityHubURL,
	SettingCommunitySiteURL:       "",
}

var monitorSettingDefaults = map[string]string{
	SettingMonitorEnabled:            "0",
	SettingMonitorRetention:          "30",
	SettingMonitorAccessLogRetention: "7",
	SettingMonitorExclude:            DefaultMonitorExcludeJSON,
	SettingMonitorTrustProxy:         "1",
}

var siteBrandingDefaults = map[string]string{
	SettingSiteName:        "姜十三论坛",
	SettingSiteSlogan:      "拾三一隅，自在交流",
	SettingSiteDescription: "",
	SettingSiteKeywords:    "",
	SettingSiteLogoMark:    "姜",
	SettingSiteLogo:        "",
	SettingSiteFavicon:     "",
	SettingSiteOGImage:     "",
	SettingSiteICPBeian:    "",
	SettingSiteICPBeianURL: "https://beian.miit.gov.cn/",
	SettingSiteFriendLinks: "[]",
}

const (
	maxFriendLinks        = 20
	maxFriendLinkName     = 32
	maxFriendLinkURL      = 512
	maxICPBeianLen        = 64
	maxICPBeianURLLen     = 512
	maxSiteDescriptionLen = 500
	maxSiteKeywordsLen    = 200
	maxSiteKeywordItems   = 20
	defaultICPBeianURL    = "https://beian.miit.gov.cn/"
)

// FriendLink 友情链接
type FriendLink struct {
	Name string `json:"name"`
	URL  string `json:"url"`
	Logo string `json:"logo,omitempty"`
}

// SiteBranding 站点品牌配置（名称、Logo、Favicon、页脚等）
type SiteBranding struct {
	Name        string       `json:"name"`
	Slogan      string       `json:"slogan"`
	Description string       `json:"description"` // 站点简介（SEO / 首页可见）
	Keywords    string       `json:"keywords"`    // SEO keywords，逗号分隔
	LogoMark    string       `json:"logo_mark"`
	Logo        string       `json:"logo"`
	Favicon     string       `json:"favicon"`
	OGImage     string       `json:"og_image"` // 默认社交分享图（Open Graph）
	ICPBeian    string       `json:"icp_beian"`
	ICPBeianURL string       `json:"icp_beian_url"`
	FriendLinks []FriendLink `json:"friend_links"`
	SiteURL     string       `json:"site_url,omitempty" gorm:"-"` // 公开站点根 URL，仅 API 填充
}

// DocumentTitle 浏览器标签标题：站点名 - 副标题（标语）
func (b SiteBranding) DocumentTitle() string {
	name := strings.TrimSpace(b.Name)
	subtitle := strings.TrimSpace(b.Slogan)
	if subtitle != "" {
		return name + " - " + subtitle
	}
	return name
}

// MetaDescription 用于 meta description：优先简介，其次标语
func (b SiteBranding) MetaDescription() string {
	if d := strings.TrimSpace(b.Description); d != "" {
		return d
	}
	return strings.TrimSpace(b.Slogan)
}

// MetaKeywords 用于 meta keywords
func (b SiteBranding) MetaKeywords() string {
	return strings.TrimSpace(b.Keywords)
}

// DefaultShareImage 默认社交预览图：专用 OG 图 → Logo → Favicon
func (b SiteBranding) DefaultShareImage() string {
	for _, u := range []string{b.OGImage, b.Logo, b.Favicon} {
		if s := strings.TrimSpace(u); s != "" {
			return s
		}
	}
	return ""
}

// GiteaSyncConfig Gitea 仓库同步配置
type GiteaSyncConfig struct {
	Enabled         bool   `json:"enabled"`
	BaseURL         string `json:"base_url"`
	Token           string `json:"token,omitempty"` // 更新时传入；回显时为空
	HasToken        bool   `json:"has_token"`
	SyncIntervalMin int    `json:"sync_interval_min"`
	Ready           bool   `json:"ready"`
	RepoCount       int64  `json:"repo_count"`
}

// CommunityConfig 社区上报配置（HubEnabled 只读：运维开关或官网域名）
type CommunityConfig struct {
	ReportEnabled bool   `json:"report_enabled"`
	HubEnabled    bool   `json:"hub_enabled"` // 只读：app.ini / 环境变量 / 官网 Host
	HubURL        string `json:"hub_url"`
	SiteURL       string `json:"site_url"` // 上报用的本站公开地址
	InstanceID    string `json:"instance_id"`
}

// MonitorConfig 网站监控采集设置
type MonitorConfig struct {
	Enabled                  bool     `json:"enabled"`
	RetentionDays            int      `json:"retention_days"`              // page_views 保留
	AccessLogRetentionDays   int      `json:"access_log_retention_days"`   // JSONL 请求日志保留
	ExcludeRules             []string `json:"exclude_rules"`
	DefaultExcludeRules      []string `json:"default_exclude_rules"` // 只读：恢复推荐规则
	TrustProxy               bool     `json:"trust_proxy"`
	AccessLogDir             string   `json:"access_log_dir"`
	IP2LocationV4Path        string   `json:"ip2location_v4_path"`
	IP2LocationV6Path        string   `json:"ip2location_v6_path"`
	IP2LocationV4Available   bool     `json:"ip2location_v4_available"`
	IP2LocationV6Available   bool     `json:"ip2location_v6_available"`
	GeoIPAvailable           bool     `json:"geoip_available"`
	GeoIPCountryPath         string   `json:"geoip_country_path"`
	GeoIPASNPath             string   `json:"geoip_asn_path"`
	GeoIPCountryAvailable    bool     `json:"geoip_country_available"`
	GeoIPASNAvailable        bool     `json:"geoip_asn_available"`
}

// OIDCConfig OIDC Provider 全局配置（应用凭证见 oauth_clients）
type OIDCConfig struct {
	Enabled      bool   `json:"enabled"`
	RootURL      string `json:"root_url"`
	Ready        bool   `json:"ready"`
	DiscoveryURL string `json:"discovery_url,omitempty"`
	AuthorizeURL string `json:"authorize_url,omitempty"`
	LogoutURL    string `json:"logout_url,omitempty"`
	GroupClaim   string `json:"group_claim"`
	AdminGroup   string `json:"admin_group"`
	UserGroup    string `json:"user_group"`
	ClientCount  int64  `json:"client_count"`
}

// ForumSettingsService 论坛全局设置
type ForumSettingsService struct {
	mu                  sync.RWMutex
	communityHubEnabled bool // 运维配置注入，非后台可改
}

func NewForumSettingsService() *ForumSettingsService {
	s := &ForumSettingsService{}
	s.ensureDefaults()
	return s
}

// SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽
func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) {
	s.mu.Lock()
	s.communityHubEnabled = enabled
	s.mu.Unlock()
}

func (s *ForumSettingsService) ensureDefaults() {
	for _, def := range forumSettingDefs {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
		}
	}
	for key, val := range feedSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range asideSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range mailSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range oidcSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range giteaSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range storageSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range siteBrandingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range friendLinkSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range communitySettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
	for key, val := range monitorSettingDefaults {
		var count int64
		model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
		if count == 0 {
			model.DB.Create(&model.ForumSetting{Key: key, Value: val})
		}
	}
}

func (s *ForumSettingsService) getString(key, fallback string) string {
	var setting model.ForumSetting
	if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
		return fallback
	}
	return setting.Value
}

func (s *ForumSettingsService) setString(key, value string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	return model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error
}

func (s *ForumSettingsService) getInt(key string, fallback int) int {
	var setting model.ForumSetting
	if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
		return fallback
	}
	v, err := strconv.Atoi(setting.Value)
	if err != nil {
		return fallback
	}
	return v
}

func (s *ForumSettingsService) setInt(key string, value int) error {
	for _, def := range forumSettingDefs {
		if def.key != key {
			continue
		}
		if value < def.min {
			return ErrInvalidSetting
		}
		if def.max > 0 && value > def.max {
			return ErrInvalidSetting
		}
		s.mu.Lock()
		defer s.mu.Unlock()
		return model.DB.Save(&model.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error
	}
	return ErrInvalidSetting
}

func (s *ForumSettingsService) Limits() ForumLimits {
	permalink := s.Permalink()
	widgets := s.AsideWidgets()
	bools := asideBoolsFromWidgets(widgets)
	return ForumLimits{
		PostEditWindowHours:      s.PostEditWindowHours(),
		CommentEditWindowMinutes: s.CommentEditWindowMinutes(),

		RateLimitPost:      s.RateLimitFor("post"),
		RateLimitComment:   s.RateLimitFor("comment"),
		RateLimitRegister:  s.RateLimitFor("register"),
		RateLimitLogin:     s.RateLimitFor("login"),
		RateLimitWindowSec: s.RateLimitWindowSec(),

		PostTitleMax:   s.PostTitleMax(),
		PostTagsMax:    s.PostTagsMax(),
		PostContentMax: s.PostContentMax(),

		CommentMax: s.CommentMax(),

		SearchKeywordMin: s.SearchKeywordMin(),
		SearchKeywordMax: s.SearchKeywordMax(),

		PageSizeDefault: s.PageSizeDefault(),

		PasswordMinLen: s.PasswordMinLen(),
		AvatarMaxMB:    s.AvatarMaxMB(),
		SignatureMax:   s.SignatureMax(),

		OpenPostsInNewTab:        s.OpenPostsInNewTab(),
		OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),

		AsideShowTagCloud:       bools.tagCloud,
		AsideShowRecentComments: bools.recentComments,
		AsideShowFriendLinks:    bools.friendLinks,
		AsideShowShowcase:       bools.showcase,
		AsideWidgets:            widgets,
		NavShowFriendLinks:      s.NavShowFriendLinks(),
		FooterShowFriendLinks:   s.FooterShowFriendLinks(),
		NavShowShowcase:         s.NavShowShowcase(),
		FooterShowShowcase:      s.FooterShowShowcase(),

		FeedListStyle: s.FeedListStyle(),
		FeedSortTabs:  s.FeedSortTabs(),

		PermalinkEnabled: permalink.Enabled,
		PermalinkExt:     permalink.Ext,
	}
}

func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
	limits := s.Limits()
	return ForumLimitsPublic{
		PostTitleMax:     limits.PostTitleMax,
		PostTagsMax:      limits.PostTagsMax,
		PostContentMax:   limits.PostContentMax,
		CommentMax:       limits.CommentMax,
		SearchKeywordMin: limits.SearchKeywordMin,
		SearchKeywordMax: limits.SearchKeywordMax,
		PageSizeDefault:  limits.PageSizeDefault,
		PasswordMinLen:   limits.PasswordMinLen,
		AvatarMaxMB:      limits.AvatarMaxMB,
		SignatureMax:     limits.SignatureMax,

		CommentEditWindowMinutes: limits.CommentEditWindowMinutes,

		OpenPostsInNewTab:        limits.OpenPostsInNewTab,
		OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,

		AsideShowTagCloud:       limits.AsideShowTagCloud,
		AsideShowRecentComments: limits.AsideShowRecentComments,
		AsideShowFriendLinks:    limits.AsideShowFriendLinks,
		AsideShowShowcase:       limits.AsideShowShowcase,
		AsideWidgets:            limits.AsideWidgets,
		NavShowFriendLinks:      limits.NavShowFriendLinks,
		FooterShowFriendLinks:   limits.FooterShowFriendLinks,
		NavShowShowcase:         limits.NavShowShowcase,
		FooterShowShowcase:      limits.FooterShowShowcase,

		FeedListStyle: limits.FeedListStyle,
		FeedSortTabs:  limits.FeedSortTabs,

		PermalinkEnabled: limits.PermalinkEnabled,
		PermalinkExt:     limits.PermalinkExt,

		MonitorPageview: s.MonitorEnabled(),
	}
}

func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
	updates := map[string]int{
		SettingPostEditWindowHours:      in.PostEditWindowHours,
		SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
		SettingRateLimitPost:            in.RateLimitPost,
		SettingRateLimitComment:         in.RateLimitComment,
		SettingRateLimitRegister:        in.RateLimitRegister,
		SettingRateLimitLogin:           in.RateLimitLogin,
		SettingRateLimitWindow:          in.RateLimitWindowSec,
		SettingPostTitleMax:             in.PostTitleMax,
		SettingPostTagsMax:              in.PostTagsMax,
		SettingPostContentMax:           in.PostContentMax,
		SettingCommentMax:               in.CommentMax,
		SettingSearchKeywordMin:         in.SearchKeywordMin,
		SettingSearchKeywordMax:         in.SearchKeywordMax,
		SettingPageSizeDefault:          in.PageSizeDefault,
		SettingPasswordMinLen:           in.PasswordMinLen,
		SettingAvatarMaxMB:              in.AvatarMaxMB,
		SettingSignatureMax:             in.SignatureMax,
	}
	if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
		return ErrInvalidSetting
	}
	for key, val := range updates {
		if err := s.setInt(key, val); err != nil {
			return err
		}
	}
	widgets := NormalizeAsideWidgets(in.AsideWidgets)
	if len(widgets) == 0 {
		widgets = asideWidgetsFromBools(in.AsideShowTagCloud, in.AsideShowRecentComments, in.AsideShowFriendLinks)
	}
	bools := asideBoolsFromWidgets(widgets)
	boolUpdates := map[string]bool{
		SettingOpenPostsInNewTab:        in.OpenPostsInNewTab,
		SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
		SettingAsideShowTagCloud:        bools.tagCloud,
		SettingAsideShowRecentComments:  bools.recentComments,
		SettingAsideShowFriendLinks:     bools.friendLinks,
		SettingAsideShowShowcase:        bools.showcase,
		SettingNavShowFriendLinks:       in.NavShowFriendLinks,
		SettingFooterShowFriendLinks:    in.FooterShowFriendLinks,
		SettingNavShowShowcase:          in.NavShowShowcase,
		SettingFooterShowShowcase:       in.FooterShowShowcase,
		SettingPermalinkEnabled:         in.PermalinkEnabled,
	}
	for key, on := range boolUpdates {
		v := "0"
		if on {
			v = "1"
		}
		if err := s.setString(key, v); err != nil {
			return err
		}
	}
	widgetsJSON, err := json.Marshal(widgets)
	if err != nil {
		return err
	}
	if err := s.setString(SettingAsideWidgets, string(widgetsJSON)); err != nil {
		return err
	}
	ext, ok := NormalizePermalinkExt(in.PermalinkExt)
	if !ok {
		return ErrInvalidSetting
	}
	if err := s.setString(SettingPermalinkExt, ext); err != nil {
		return err
	}
	style, ok := NormalizeFeedListStyle(in.FeedListStyle)
	if !ok {
		return ErrInvalidSetting
	}
	if err := s.setString(SettingFeedListStyle, style); err != nil {
		return err
	}
	tabs := NormalizeFeedSortTabs(in.FeedSortTabs)
	tabsJSON, err := json.Marshal(tabs)
	if err != nil {
		return err
	}
	if err := s.setString(SettingFeedSortTabs, string(tabsJSON)); err != nil {
		return err
	}
	return nil
}

func (s *ForumSettingsService) PostEditWindowHours() int {
	return s.getInt(SettingPostEditWindowHours, 24)
}

func (s *ForumSettingsService) CommentEditWindowMinutes() int {
	return s.getInt(SettingCommentEditWindowMinutes, 3)
}

func (s *ForumSettingsService) RateLimitFor(action string) int {
	switch action {
	case "post":
		return s.getInt(SettingRateLimitPost, 10)
	case "comment":
		return s.getInt(SettingRateLimitComment, 10)
	case "register":
		return s.getInt(SettingRateLimitRegister, 10)
	case "login", "admin_login":
		return s.getInt(SettingRateLimitLogin, 10)
	default:
		return 10
	}
}

func (s *ForumSettingsService) RateLimitWindowSec() int {
	return s.getInt(SettingRateLimitWindow, 60)
}

func (s *ForumSettingsService) PostTitleMax() int   { return s.getInt(SettingPostTitleMax, 128) }
func (s *ForumSettingsService) PostTagsMax() int    { return s.getInt(SettingPostTagsMax, 256) }
func (s *ForumSettingsService) PostContentMax() int { return s.getInt(SettingPostContentMax, 50000) }
func (s *ForumSettingsService) CommentMax() int     { return s.getInt(SettingCommentMax, 5000) }

func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingSearchKeywordMin, 1) }
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }

func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }

func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
func (s *ForumSettingsService) AvatarMaxMB() int    { return s.getInt(SettingAvatarMaxMB, 2) }
func (s *ForumSettingsService) SignatureMax() int   { return s.getInt(SettingSignatureMax, 200) }

func (s *ForumSettingsService) OpenPostsInNewTab() bool {
	return s.getString(SettingOpenPostsInNewTab, "1") == "1"
}

func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
	return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}

// FriendLinkReciprocalCheckEnabled 是否启用友链回链检测；缺省为关闭
func (s *ForumSettingsService) FriendLinkReciprocalCheckEnabled() bool {
	return s.getString(SettingFriendLinkReciprocalCheck, "0") == "1"
}

// NavShowFriendLinks 左侧栏「站点」是否展示友链入口；缺省开启
func (s *ForumSettingsService) NavShowFriendLinks() bool {
	return s.getString(SettingNavShowFriendLinks, "1") == "1"
}

// FooterShowFriendLinks 页脚是否展示友链入口；缺省开启
func (s *ForumSettingsService) FooterShowFriendLinks() bool {
	return s.getString(SettingFooterShowFriendLinks, "1") == "1"
}

// NavShowShowcase 左侧栏「站点」是否展示开源展柜入口；缺省关闭
func (s *ForumSettingsService) NavShowShowcase() bool {
	return s.getString(SettingNavShowShowcase, "0") == "1"
}

// FooterShowShowcase 页脚是否展示开源展柜入口；缺省关闭
func (s *ForumSettingsService) FooterShowShowcase() bool {
	return s.getString(SettingFooterShowShowcase, "0") == "1"
}

// AsideShowShowcase 右侧栏是否展示开源展柜组件
func (s *ForumSettingsService) AsideShowShowcase() bool {
	for _, w := range s.AsideWidgets() {
		if w.ID == AsideWidgetShowcase {
			return w.Enabled
		}
	}
	return s.getString(SettingAsideShowShowcase, "0") == "1"
}

func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
	v := "0"
	if enabled {
		v = "1"
	}
	return s.setString(SettingFriendLinkReciprocalCheck, v)
}

func (s *ForumSettingsService) SetNavShowFriendLinks(enabled bool) error {
	v := "0"
	if enabled {
		v = "1"
	}
	return s.setString(SettingNavShowFriendLinks, v)
}

func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
	v := "0"
	if enabled {
		v = "1"
	}
	return s.setString(SettingFooterShowFriendLinks, v)
}

func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error {
	v := "0"
	if enabled {
		v = "1"
	}
	return s.setString(SettingNavShowShowcase, v)
}

func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error {
	v := "0"
	if enabled {
		v = "1"
	}
	return s.setString(SettingFooterShowShowcase, v)
}

// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关（与 aside_widgets 同步）
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
	return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled)
}

// SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关（与 aside_widgets 同步）
func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error {
	return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled)
}

func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error {
	widgets := s.AsideWidgets()
	found := false
	for i := range widgets {
		if widgets[i].ID == widgetID {
			widgets[i].Enabled = enabled
			found = true
			break
		}
	}
	if !found {
		widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
	}
	widgets = NormalizeAsideWidgets(widgets)
	bools := asideBoolsFromWidgets(widgets)
	payload, err := json.Marshal(widgets)
	if err != nil {
		return err
	}
	if err := s.setString(SettingAsideWidgets, string(payload)); err != nil {
		return err
	}
	v := "0"
	switch widgetID {
	case AsideWidgetFriendLinks:
		if bools.friendLinks {
			v = "1"
		}
	case AsideWidgetShowcase:
		if bools.showcase {
			v = "1"
		}
	default:
		if enabled {
			v = "1"
		}
	}
	return s.setString(boolSettingKey, v)
}

type asideWidgetBools struct {
	tagCloud       bool
	recentComments bool
	friendLinks    bool
	showcase       bool
}

func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
	return []AsideWidget{
		{ID: AsideWidgetTagCloud, Enabled: tagCloud},
		{ID: AsideWidgetRecentComments, Enabled: recentComments},
		{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
		{ID: AsideWidgetShowcase, Enabled: false},
	}
}

func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
	out := asideWidgetBools{}
	for _, w := range widgets {
		switch w.ID {
		case AsideWidgetTagCloud:
			out.tagCloud = w.Enabled
		case AsideWidgetRecentComments:
			out.recentComments = w.Enabled
		case AsideWidgetFriendLinks:
			out.friendLinks = w.Enabled
		case AsideWidgetShowcase:
			out.showcase = w.Enabled
		}
	}
	return out
}

func isValidAsideWidgetID(id string) bool {
	switch id {
	case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
		return true
	default:
		return false
	}
}

// NormalizeAsideWidgets 校验并补全右侧栏组件列表（顺序保留，缺失项按默认顺序追加）
func NormalizeAsideWidgets(in []AsideWidget) []AsideWidget {
	seen := make(map[string]bool, len(asideWidgetDefaultOrder))
	out := make([]AsideWidget, 0, len(asideWidgetDefaultOrder))
	for _, w := range in {
		id := strings.TrimSpace(w.ID)
		if !isValidAsideWidgetID(id) || seen[id] {
			continue
		}
		seen[id] = true
		out = append(out, AsideWidget{ID: id, Enabled: w.Enabled})
	}
	for _, id := range asideWidgetDefaultOrder {
		if seen[id] {
			continue
		}
		out = append(out, AsideWidget{ID: id, Enabled: false})
	}
	return out
}

func (s *ForumSettingsService) AsideWidgets() []AsideWidget {
	raw := strings.TrimSpace(s.getString(SettingAsideWidgets, ""))
	if raw != "" {
		var widgets []AsideWidget
		if err := json.Unmarshal([]byte(raw), &widgets); err == nil {
			normalized := NormalizeAsideWidgets(widgets)
			if len(normalized) > 0 {
				return normalized
			}
		}
	}
	return asideWidgetsFromBools(s.AsideShowTagCloud(), s.AsideShowRecentComments(), s.AsideShowFriendLinks())
}

func (s *ForumSettingsService) AsideShowTagCloud() bool {
	return s.getString(SettingAsideShowTagCloud, "0") == "1"
}

func (s *ForumSettingsService) AsideShowRecentComments() bool {
	return s.getString(SettingAsideShowRecentComments, "0") == "1"
}

func (s *ForumSettingsService) AsideShowFriendLinks() bool {
	return s.getString(SettingAsideShowFriendLinks, "1") == "1"
}

// NormalizeFeedListStyle 校验首页列表样式
func NormalizeFeedListStyle(v string) (string, bool) {
	switch strings.TrimSpace(strings.ToLower(v)) {
	case "title", "":
		return "title", true
	case "excerpt":
		return "excerpt", true
	case "thumbnail":
		return "thumbnail", true
	default:
		return "", false
	}
}

func (s *ForumSettingsService) FeedListStyle() string {
	v, ok := NormalizeFeedListStyle(s.getString(SettingFeedListStyle, feedSettingDefaults[SettingFeedListStyle]))
	if !ok {
		return "title"
	}
	return v
}

func isValidFeedSortTabID(id string) bool {
	switch id {
	case FeedSortReply, FeedSortLatest, FeedSortHot:
		return true
	default:
		return false
	}
}

// NormalizeFeedSortTabs 校验并补全 Feed 排序标签（顺序保留，至少一项启用）
func NormalizeFeedSortTabs(in []FeedSortTab) []FeedSortTab {
	seen := make(map[string]bool, len(feedSortTabDefaultOrder))
	out := make([]FeedSortTab, 0, len(feedSortTabDefaultOrder))
	for _, t := range in {
		id := strings.TrimSpace(t.ID)
		if !isValidFeedSortTabID(id) || seen[id] {
			continue
		}
		seen[id] = true
		label := strings.TrimSpace(t.Label)
		if label == "" {
			label = feedSortTabDefaultLabels[id]
		}
		out = append(out, FeedSortTab{ID: id, Label: label, Enabled: t.Enabled})
	}
	for _, id := range feedSortTabDefaultOrder {
		if seen[id] {
			continue
		}
		out = append(out, FeedSortTab{
			ID:      id,
			Label:   feedSortTabDefaultLabels[id],
			Enabled: true,
		})
	}
	hasEnabled := false
	for _, t := range out {
		if t.Enabled {
			hasEnabled = true
			break
		}
	}
	if !hasEnabled && len(out) > 0 {
		out[0].Enabled = true
	}
	return out
}

func (s *ForumSettingsService) FeedSortTabs() []FeedSortTab {
	raw := strings.TrimSpace(s.getString(SettingFeedSortTabs, ""))
	if raw == "" {
		raw = feedSettingDefaults[SettingFeedSortTabs]
	}
	var tabs []FeedSortTab
	if raw != "" {
		_ = json.Unmarshal([]byte(raw), &tabs)
	}
	return NormalizeFeedSortTabs(tabs)
}

// DefaultFeedSort 返回配置中第一个启用的排序键
func (s *ForumSettingsService) DefaultFeedSort() string {
	for _, t := range s.FeedSortTabs() {
		if t.Enabled {
			return t.ID
		}
	}
	return FeedSortReply
}

// MailConfig 读取 SMTP 配置（密码不回显明文）
func (s *ForumSettingsService) MailConfig() MailConfig {
	port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
	if port <= 0 {
		port = 465
	}
	password := s.getString(SettingSMTPPassword, "")
	return MailConfig{
		Enabled:     s.getString(SettingSMTPEnabled, "0") == "1",
		Host:        s.getString(SettingSMTPHost, ""),
		Port:        port,
		Username:    s.getString(SettingSMTPUsername, ""),
		Password:    password,
		From:        s.getString(SettingSMTPFrom, ""),
		FromName:    s.getString(SettingSMTPFromName, "姜十三论坛"),
		Encryption:  normalizeEncryption(s.getString(SettingSMTPEncryption, "ssl")),
		HasPassword: password != "",
	}
}

// MailConfigPublic 管理端回显（不含密码明文）
func (s *ForumSettingsService) MailConfigPublic() MailConfig {
	cfg := s.MailConfig()
	cfg.Password = ""
	return cfg
}

// MailReady 邮件服务是否可用于发信
func (s *ForumSettingsService) MailReady() bool {
	cfg := s.MailConfig()
	return cfg.Enabled &&
		strings.TrimSpace(cfg.Host) != "" &&
		cfg.Port > 0 &&
		strings.TrimSpace(cfg.From) != "" &&
		strings.TrimSpace(cfg.Username) != "" &&
		cfg.HasPassword
}

// UpdateMailConfig 更新 SMTP 配置；密码为空表示保持原值
func (s *ForumSettingsService) UpdateMailConfig(in MailConfig) error {
	enc := normalizeEncryption(in.Encryption)
	if enc != "none" && enc != "starttls" && enc != "ssl" {
		return ErrInvalidSetting
	}
	port := in.Port
	if port <= 0 {
		port = 465
	}
	enabled := "0"
	if in.Enabled {
		enabled = "1"
	}
	updates := map[string]string{
		SettingSMTPEnabled:    enabled,
		SettingSMTPHost:       strings.TrimSpace(in.Host),
		SettingSMTPPort:       strconv.Itoa(port),
		SettingSMTPUsername:   strings.TrimSpace(in.Username),
		SettingSMTPFrom:       strings.TrimSpace(in.From),
		SettingSMTPFromName:   strings.TrimSpace(in.FromName),
		SettingSMTPEncryption: enc,
	}
	if strings.TrimSpace(in.Password) != "" {
		updates[SettingSMTPPassword] = in.Password
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

// OIDCConfig 读取 OIDC 全局配置
func (s *ForumSettingsService) OIDCConfig() OIDCConfig {
	root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
	clientCount := CountEnabledOAuthClients()
	cfg := OIDCConfig{
		Enabled:     s.getString(SettingOIDCEnabled, "0") == "1",
		RootURL:     root,
		GroupClaim:  strings.TrimSpace(s.getString(SettingOIDCGroupClaim, "groups")),
		AdminGroup:  strings.TrimSpace(s.getString(SettingOIDCAdminGroup, "gitea-admin")),
		UserGroup:   strings.TrimSpace(s.getString(SettingOIDCUserGroup, "gitea-users")),
		ClientCount: clientCount,
	}
	if cfg.GroupClaim == "" {
		cfg.GroupClaim = "groups"
	}
	cfg.Ready = cfg.Enabled && cfg.RootURL != "" && clientCount > 0
	if root != "" {
		cfg.DiscoveryURL = root + "/.well-known/openid-configuration"
		cfg.AuthorizeURL = root + "/oauth/authorize"
		cfg.LogoutURL = root + "/oauth/logout"
	}
	return cfg
}

// OIDCConfigPublic 管理端回显
func (s *ForumSettingsService) OIDCConfigPublic() OIDCConfig {
	return s.OIDCConfig()
}

// UpdateOIDCConfig 更新 OIDC 全局配置（不含 OAuth 应用凭证）
func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
	root := normalizeRootURL(in.RootURL)
	if root != "" && !strings.HasPrefix(root, "http://") && !strings.HasPrefix(root, "https://") {
		return ErrInvalidSetting
	}
	enabled := "0"
	if in.Enabled {
		enabled = "1"
	}
	groupClaim := strings.TrimSpace(in.GroupClaim)
	if groupClaim == "" {
		groupClaim = "groups"
	}
	adminGroup := strings.TrimSpace(in.AdminGroup)
	userGroup := strings.TrimSpace(in.UserGroup)
	updates := map[string]string{
		SettingOIDCEnabled:    enabled,
		SettingOIDCRootURL:    root,
		SettingOIDCGroupClaim: groupClaim,
		SettingOIDCAdminGroup: adminGroup,
		SettingOIDCUserGroup:  userGroup,
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

// GiteaSyncConfig 读取 Gitea 同步配置（含 Token 明文，供服务内部使用）
func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
	interval, _ := strconv.Atoi(s.getString(SettingGiteaSyncIntervalMin, "60"))
	if interval < 5 {
		interval = 5
	}
	if interval > 24*60 {
		interval = 24 * 60
	}
	token := s.getString(SettingGiteaToken, "")
	base := normalizeRootURL(s.getString(SettingGiteaBaseURL, ""))
	cfg := GiteaSyncConfig{
		Enabled:         s.getString(SettingGiteaSyncEnabled, "0") == "1",
		BaseURL:         base,
		Token:           token,
		HasToken:        token != "",
		SyncIntervalMin: interval,
	}
	cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
	var n int64
	model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false).Count(&n)
	cfg.RepoCount = n
	return cfg
}

// GiteaSyncConfigPublic 管理端回显（不含 Token 明文）
func (s *ForumSettingsService) GiteaSyncConfigPublic() GiteaSyncConfig {
	cfg := s.GiteaSyncConfig()
	cfg.Token = ""
	return cfg
}

// UpdateGiteaSyncConfig 更新同步配置；Token 为空表示保持原值
func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
	base := normalizeRootURL(in.BaseURL)
	if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
		return ErrInvalidSetting
	}
	interval := in.SyncIntervalMin
	if interval <= 0 {
		interval = 60
	}
	if interval < 5 {
		interval = 5
	}
	if interval > 24*60 {
		interval = 24 * 60
	}
	enabled := "0"
	if in.Enabled {
		enabled = "1"
	}
	updates := map[string]string{
		SettingGiteaSyncEnabled:     enabled,
		SettingGiteaBaseURL:         base,
		SettingGiteaSyncIntervalMin: strconv.Itoa(interval),
	}
	if strings.TrimSpace(in.Token) != "" {
		updates[SettingGiteaToken] = strings.TrimSpace(in.Token)
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

// CommunityConfig 读取社区上报配置（无请求上下文；官网需靠已存 ROOT_URL / site_url）
func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
	return s.CommunityConfigForRequest("")
}

// CommunityConfigForRequest 读取社区配置；requestHint 可为 Origin / 公开 URL，用于识别官网 Host
func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
	return CommunityConfig{
		ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
		HubEnabled:    s.CommunityHubEnabled(requestHint),
		HubURL:        DefaultCommunityHubURL,
		SiteURL:       s.CommunitySiteURL(requestHint),
		InstanceID:    strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
	}
}

// CommunityHubEnabled 是否作为社区枢纽：运维开关，或本站即为官方演示站域名
func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool {
	s.mu.RLock()
	ops := s.communityHubEnabled
	s.mu.RUnlock()
	if ops {
		return true
	}
	official := officialCommunityHubHost()
	if official == "" {
		return false
	}
	for _, cand := range []string{
		s.getString(SettingOIDCRootURL, ""),
		s.getString(SettingCommunitySiteURL, ""),
		requestHint,
	} {
		if hostFromURLOrHost(cand) == official {
			return true
		}
	}
	return false
}

// officialCommunityHubHost 官方枢纽规范化主机名（如 bbs.iioio.com）
func officialCommunityHubHost() string {
	return hostFromURLOrHost(DefaultCommunityHubURL)
}

// hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名（小写、去 www、去端口）
func hostFromURLOrHost(raw string) string {
	raw = strings.TrimSpace(strings.ToLower(raw))
	if raw == "" {
		return ""
	}
	if strings.Contains(raw, "://") {
		u, err := url.Parse(raw)
		if err != nil || u.Host == "" {
			return ""
		}
		raw = u.Host
	} else if i := strings.IndexAny(raw, "/?"); i >= 0 {
		raw = raw[:i]
	}
	if raw == "" {
		return ""
	}
	if h, _, err := net.SplitHostPort(raw); err == nil {
		raw = h
	}
	return strings.TrimPrefix(raw, "www.")
}

// CommunitySiteURL 上报用的本站公开地址：已持久化 > OIDC ROOT_URL > 请求 Origin
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
	if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
		return strings.TrimRight(u, "/")
	}
	return s.SitePublicBaseURL(requestOrigin)
}

// EnsureCommunitySiteURL 在开启上报时确保有可用的本站公开地址；origin 可来自当前管理请求
func (s *ForumSettingsService) EnsureCommunitySiteURL(requestOrigin string) (string, error) {
	if u := s.CommunitySiteURL(requestOrigin); u != "" {
		// 若仅靠 Origin 推断，持久化以便后台 ticker 使用
		if normalizeRootURL(s.getString(SettingCommunitySiteURL, "")) == "" &&
			normalizeRootURL(s.getString(SettingOIDCRootURL, "")) == "" {
			if err := s.setString(SettingCommunitySiteURL, u); err != nil {
				return "", err
			}
		}
		return u, nil
	}
	return "", errors.New("无法确定本站公开地址：请先在 OIDC 设置中填写 ROOT_URL，或通过浏览器管理端开启上报")
}

// EnsureCommunityInstanceID 确保本机有稳定的匿名实例 ID
func (s *ForumSettingsService) EnsureCommunityInstanceID() (string, error) {
	id := strings.TrimSpace(s.getString(SettingCommunityInstanceID, ""))
	if id != "" {
		return id, nil
	}
	id = newCommunityInstanceID()
	if err := s.setString(SettingCommunityInstanceID, id); err != nil {
		return "", err
	}
	return id, nil
}

// UpdateCommunityConfig 仅更新上报开关；忽略客户端传入的 hub_url / site_url
func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasReportEnabled bool, err error) {
	wasReportEnabled = s.getString(SettingCommunityReportEnabled, "0") == "1"
	report := "0"
	if in.ReportEnabled {
		report = "1"
	}
	if err := s.setString(SettingCommunityReportEnabled, report); err != nil {
		return wasReportEnabled, err
	}
	if in.ReportEnabled {
		if _, err := s.EnsureCommunityInstanceID(); err != nil {
			return wasReportEnabled, err
		}
	}
	return wasReportEnabled, nil
}

// DefaultMonitorExcludeRules 导出推荐排除规则（供设置页「恢复默认」）
func DefaultMonitorExcludeRules() []string {
	return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
}

func normalizeMonitorRetentionDays(v int) int {
	if v < 1 {
		return 1
	}
	if v > 365 {
		return 365
	}
	return v
}

// MonitorConfig 读取网站监控设置
func (s *ForumSettingsService) MonitorConfig() MonitorConfig {
	retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30"))
	accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7"))
	return MonitorConfig{
		Enabled:                s.getString(SettingMonitorEnabled, "0") == "1",
		RetentionDays:          normalizeMonitorRetentionDays(retention),
		AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention),
		ExcludeRules:           parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)),
		DefaultExcludeRules:    DefaultMonitorExcludeRules(),
		TrustProxy:             s.getString(SettingMonitorTrustProxy, "1") == "1",
	}
}

// MonitorEnabled 采集是否开启
func (s *ForumSettingsService) MonitorEnabled() bool {
	return s.getString(SettingMonitorEnabled, "0") == "1"
}

// UpdateMonitorConfig 更新网站监控设置
func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error {
	enabled := "0"
	if in.Enabled {
		enabled = "1"
	}
	retention := normalizeMonitorRetentionDays(in.RetentionDays)
	accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays)
	trust := "0"
	if in.TrustProxy {
		trust = "1"
	}
	rules := in.ExcludeRules
	if rules == nil {
		rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
	}
	raw, err := json.Marshal(normalizeMonitorExclude(rules))
	if err != nil {
		return err
	}
	updates := map[string]string{
		SettingMonitorEnabled:            enabled,
		SettingMonitorRetention:          strconv.Itoa(retention),
		SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention),
		SettingMonitorExclude:            string(raw),
		SettingMonitorTrustProxy:         trust,
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

func parseMonitorExcludeJSON(raw string) []string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		raw = DefaultMonitorExcludeJSON
	}
	var rules []string
	if err := json.Unmarshal([]byte(raw), &rules); err != nil {
		_ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules)
	}
	return normalizeMonitorExclude(rules)
}

func normalizeMonitorExclude(rules []string) []string {
	seen := make(map[string]struct{}, len(rules))
	out := make([]string, 0, len(rules))
	for _, r := range rules {
		r = strings.TrimSpace(r)
		if r == "" {
			continue
		}
		if _, ok := seen[r]; ok {
			continue
		}
		seen[r] = struct{}{}
		out = append(out, r)
	}
	return out
}

// StorageConfig 读取上传存储配置（含密钥明文，供内部使用）
func (s *ForumSettingsService) StorageConfig() StorageConfig {
	secret := s.getString(SettingStorageSecretKey, "")
	region := strings.TrimSpace(s.getString(SettingStorageRegion, "us-east-1"))
	if region == "" {
		region = "us-east-1"
	}
	cfg := StorageConfig{
		Type:           normalizeStorageType(s.getString(SettingStorageType, "local")),
		Endpoint:       strings.TrimSpace(s.getString(SettingStorageEndpoint, "")),
		Region:         region,
		Bucket:         strings.TrimSpace(s.getString(SettingStorageBucket, "")),
		AccessKey:      strings.TrimSpace(s.getString(SettingStorageAccessKey, "")),
		SecretKey:      secret,
		PublicBaseURL:  normalizeRootURL(s.getString(SettingStoragePublicBaseURL, "")),
		Prefix:         normalizeObjectPrefix(s.getString(SettingStoragePrefix, "")),
		ForcePathStyle: s.getString(SettingStorageForcePathStyle, "1") == "1",
		HasSecretKey:   secret != "",
		ImageDelivery:  normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP)),
	}
	cfg.Ready = cfg.Type == "local" || (cfg.Endpoint != "" && cfg.Bucket != "" &&
		cfg.AccessKey != "" && cfg.HasSecretKey && cfg.PublicBaseURL != "")
	return cfg
}

// ImageDelivery 图片展示方案：webp | original
func (s *ForumSettingsService) ImageDelivery() string {
	return normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP))
}

// StorageConfigPublic 管理端回显（不含 Secret Key 明文）
func (s *ForumSettingsService) StorageConfigPublic() StorageConfig {
	cfg := s.StorageConfig()
	cfg.SecretKey = ""
	return cfg
}

// UpdateStorageConfig 更新存储配置；Secret Key 为空表示保持原值
func (s *ForumSettingsService) UpdateStorageConfig(in StorageConfig) error {
	typ := normalizeStorageType(in.Type)
	if typ != "local" && typ != "s3" {
		return ErrInvalidSetting
	}
	endpoint := strings.TrimSpace(in.Endpoint)
	region := strings.TrimSpace(in.Region)
	if region == "" {
		region = "us-east-1"
	}
	bucket := strings.TrimSpace(in.Bucket)
	accessKey := strings.TrimSpace(in.AccessKey)
	publicBase := normalizeRootURL(in.PublicBaseURL)
	prefix := normalizeObjectPrefix(in.Prefix)
	forcePath := "0"
	if in.ForcePathStyle {
		forcePath = "1"
	}

	if typ == "s3" {
		if endpoint == "" || bucket == "" || publicBase == "" {
			return errors.New("启用 S3 时须填写 Endpoint、Bucket 与公开访问地址")
		}
		if _, _, err := parseS3Endpoint(endpoint); err != nil {
			return err
		}
		if !strings.HasPrefix(publicBase, "http://") && !strings.HasPrefix(publicBase, "https://") {
			return errors.New("公开访问地址须以 http:// 或 https:// 开头")
		}
		existingSecret := s.getString(SettingStorageSecretKey, "")
		secret := strings.TrimSpace(in.SecretKey)
		if accessKey == "" {
			accessKey = strings.TrimSpace(s.getString(SettingStorageAccessKey, ""))
		}
		if secret == "" {
			secret = existingSecret
		}
		if accessKey == "" || secret == "" {
			return errors.New("启用 S3 时须填写 Access Key 与 Secret Key")
		}
	}

	delivery := normalizeImageDelivery(in.ImageDelivery)
	updates := map[string]string{
		SettingStorageType:           typ,
		SettingStorageEndpoint:       endpoint,
		SettingStorageRegion:         region,
		SettingStorageBucket:         bucket,
		SettingStorageAccessKey:      accessKey,
		SettingStoragePublicBaseURL:  publicBase,
		SettingStoragePrefix:         prefix,
		SettingStorageForcePathStyle: forcePath,
		SettingStorageImageDelivery:  delivery,
	}
	if strings.TrimSpace(in.SecretKey) != "" {
		updates[SettingStorageSecretKey] = strings.TrimSpace(in.SecretKey)
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

// SiteBranding 读取站点品牌配置
func (s *ForumSettingsService) SiteBranding() SiteBranding {
	name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
	if name == "" {
		name = siteBrandingDefaults[SettingSiteName]
	}
	mark := strings.TrimSpace(s.getString(SettingSiteLogoMark, siteBrandingDefaults[SettingSiteLogoMark]))
	if mark == "" {
		mark = siteBrandingDefaults[SettingSiteLogoMark]
	}
	// 字标取首个字符（支持中文）
	runes := []rune(mark)
	if len(runes) > 1 {
		mark = string(runes[0])
	}
	links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
	links = EnrichFriendLinksLogos(links)
	if err := s.maybePersistEnrichedFriendLinks(links); err != nil {
		// 回填失败不阻断读取
		_ = err
	}
	return SiteBranding{
		Name:        name,
		Slogan:      strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
		Description: strings.TrimSpace(s.getString(SettingSiteDescription, "")),
		Keywords:    strings.TrimSpace(s.getString(SettingSiteKeywords, "")),
		LogoMark:    mark,
		Logo:        strings.TrimSpace(s.getString(SettingSiteLogo, "")),
		Favicon:     strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
		OGImage:     strings.TrimSpace(s.getString(SettingSiteOGImage, "")),
		ICPBeian:    strings.TrimSpace(s.getString(SettingSiteICPBeian, "")),
		ICPBeianURL: strings.TrimSpace(s.getString(SettingSiteICPBeianURL, defaultICPBeianURL)),
		FriendLinks: links,
	}
}

// UpdateSiteBranding 更新品牌文案与页脚信息；Logo/Favicon URL 由上传接口单独写入
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
	name := strings.TrimSpace(in.Name)
	if name == "" {
		return ErrInvalidSetting
	}
	if len([]rune(name)) > 64 {
		return ErrInvalidSetting
	}
	mark := strings.TrimSpace(in.LogoMark)
	if mark == "" {
		mark = siteBrandingDefaults[SettingSiteLogoMark]
	}
	runes := []rune(mark)
	if len(runes) > 1 {
		mark = string(runes[0])
	}
	slogan := strings.TrimSpace(in.Slogan)
	if len([]rune(slogan)) > 200 {
		return ErrInvalidSetting
	}
	description := strings.TrimSpace(in.Description)
	if len([]rune(description)) > maxSiteDescriptionLen {
		return ErrInvalidSetting
	}
	keywords, err := normalizeSiteKeywords(in.Keywords)
	if err != nil {
		return err
	}
	icp := strings.TrimSpace(in.ICPBeian)
	if len([]rune(icp)) > maxICPBeianLen {
		return ErrInvalidSetting
	}
	icpURL, err := normalizeOptionalHTTPURL(in.ICPBeianURL, defaultICPBeianURL, maxICPBeianURLLen)
	if err != nil {
		return err
	}
	links, err := normalizeFriendLinks(in.FriendLinks)
	if err != nil {
		return err
	}
	linksJSON, err := json.Marshal(links)
	if err != nil {
		return err
	}
	updates := map[string]string{
		SettingSiteName:        name,
		SettingSiteSlogan:      slogan,
		SettingSiteDescription: description,
		SettingSiteKeywords:    keywords,
		SettingSiteLogoMark:    mark,
		SettingSiteICPBeian:    icp,
		SettingSiteICPBeianURL: icpURL,
		SettingSiteFriendLinks: string(linksJSON),
	}
	for key, val := range updates {
		if err := s.setString(key, val); err != nil {
			return err
		}
	}
	return nil
}

// SetSiteLogo 写入 Logo URL（空串表示清除）
func (s *ForumSettingsService) SetSiteLogo(url string) error {
	return s.setString(SettingSiteLogo, strings.TrimSpace(url))
}

// SetSiteFavicon 写入 Favicon URL（空串表示清除）
func (s *ForumSettingsService) SetSiteFavicon(url string) error {
	return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
}

// SetSiteOGImage 写入默认社交分享图 URL（空串表示清除）
func (s *ForumSettingsService) SetSiteOGImage(url string) error {
	return s.setString(SettingSiteOGImage, strings.TrimSpace(url))
}

// normalizeSiteKeywords 统一中英文分隔符，去重并限制数量/长度
func normalizeSiteKeywords(raw string) (string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", nil
	}
	if len([]rune(raw)) > maxSiteKeywordsLen {
		return "", ErrInvalidSetting
	}
	raw = strings.NewReplacer("，", ",", "、", ",", ";", ",", "；", ",").Replace(raw)
	parts := strings.Split(raw, ",")
	out := make([]string, 0, len(parts))
	seen := make(map[string]struct{}, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		if _, ok := seen[p]; ok {
			continue
		}
		seen[p] = struct{}{}
		out = append(out, p)
	}
	if len(out) > maxSiteKeywordItems {
		return "", ErrInvalidSetting
	}
	return strings.Join(out, ","), nil
}

// JoinSEOKeywords 合并页面级与站点级关键词（逗号分隔）
func JoinSEOKeywords(parts ...string) string {
	out := make([]string, 0, len(parts))
	seen := make(map[string]struct{}, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		for _, p := range strings.Split(strings.NewReplacer("，", ",", "、", ",").Replace(part), ",") {
			p = strings.TrimSpace(p)
			if p == "" {
				continue
			}
			if _, ok := seen[p]; ok {
				continue
			}
			seen[p] = struct{}{}
			out = append(out, p)
		}
	}
	return strings.Join(out, ",")
}

// normalizeOptionalHTTPURL 空值回落到 defaultURL；非空须为 http(s)
func normalizeOptionalHTTPURL(raw, defaultURL string, maxLen int) (string, error) {
	href := strings.TrimSpace(raw)
	if href == "" {
		return defaultURL, nil
	}
	if len(href) > maxLen {
		return "", ErrInvalidSetting
	}
	u, err := url.Parse(href)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return "", ErrInvalidSetting
	}
	scheme := strings.ToLower(u.Scheme)
	if scheme != "http" && scheme != "https" {
		return "", ErrInvalidSetting
	}
	return href, nil
}

func parseFriendLinksJSON(raw string) []FriendLink {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return []FriendLink{}
	}
	var links []FriendLink
	if err := json.Unmarshal([]byte(raw), &links); err != nil {
		return []FriendLink{}
	}
	out, err := normalizeFriendLinks(links)
	if err != nil {
		return []FriendLink{}
	}
	return out
}

func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
	if len(in) > maxFriendLinks {
		return nil, ErrInvalidSetting
	}
	out := make([]FriendLink, 0, len(in))
	for _, item := range in {
		name := strings.TrimSpace(item.Name)
		href := strings.TrimSpace(item.URL)
		if name == "" && href == "" {
			continue
		}
		if name == "" || href == "" {
			return nil, ErrInvalidSetting
		}
		if len([]rune(name)) > maxFriendLinkName || len(href) > maxFriendLinkURL {
			return nil, ErrInvalidSetting
		}
		u, err := url.Parse(href)
		if err != nil || u.Scheme == "" || u.Host == "" {
			return nil, ErrInvalidSetting
		}
		scheme := strings.ToLower(u.Scheme)
		if scheme != "http" && scheme != "https" {
			return nil, ErrInvalidSetting
		}
		out = append(out, FriendLink{Name: name, URL: href, Logo: normalizeFriendLinkLogoOptional(item.Logo)})
	}
	if out == nil {
		out = []FriendLink{}
	}
	return out, nil
}

func normalizeRootURL(raw string) string {
	return strings.TrimRight(strings.TrimSpace(raw), "/")
}

func normalizeRedirectURIs(raw string) string {
	parts := splitRedirectURIs(raw)
	return strings.Join(parts, ",")
}

func splitRedirectURIs(raw string) []string {
	raw = strings.ReplaceAll(raw, "\r\n", "\n")
	raw = strings.ReplaceAll(raw, "\n", ",")
	parts := strings.Split(raw, ",")
	out := make([]string, 0, len(parts))
	seen := map[string]struct{}{}
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		if _, ok := seen[p]; ok {
			continue
		}
		seen[p] = struct{}{}
		out = append(out, p)
	}
	return out
}

// NormalizeSearchKeyword 校验并规范化搜索关键词，空字符串表示无搜索
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
	kw := trimRunes(keyword)
	if kw == "" {
		return "", nil
	}
	minLen := s.SearchKeywordMin()
	maxLen := s.SearchKeywordMax()
	runeLen := runeLen(kw)
	if minLen > 0 && runeLen < minLen {
		return "", ErrSearchKeywordTooShort
	}
	if maxLen > 0 && runeLen > maxLen {
		return "", ErrSearchKeywordTooLong
	}
	return kw, nil
}

// NormalizePageSize 规范化分页大小
func (s *ForumSettingsService) NormalizePageSize(size int) int {
	if size < 1 {
		return s.PageSizeDefault()
	}
	if size > pageSizeAPIMax {
		return pageSizeAPIMax
	}
	return size
}

// ValidateTextLength 校验文本长度，max=0 表示不限
func (s *ForumSettingsService) ValidateTextLength(text string, max int, tooLongErr error) error {
	if max <= 0 {
		return nil
	}
	if runeLen(text) > max {
		return tooLongErr
	}
	return nil
}
