package web

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"ops-assistant/internal/channel"
	"ops-assistant/internal/core/ecode"
	"ops-assistant/internal/core/ops"
	"ops-assistant/internal/core/runbook"
	"ops-assistant/internal/service"
	"ops-assistant/models"
	"ops-assistant/version"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

const (
	cookieUserNew  = "ops_user"
	cookieTokenNew = "ops_token"
	cookieUserOld  = "xiaji_user"
	cookieTokenOld = "xiaji_token"
)

type WebServer struct {
	db        *gorm.DB
	dbPath    string
	baseDir   string
	finance   *service.FinanceService
	port      int
	username  string
	password  string
	secretKey string
	reloadFn  func() (string, error)
}

type CurrentUser struct {
	Username    string          `json:"username"`
	Role        string          `json:"role"`
	UserID      int64           `json:"user_id"`
	Permissions map[string]bool `json:"-"`
	PermList    []string        `json:"permissions"`
	Flags       map[string]bool `json:"flags"`
	Caps        map[string]bool `json:"effective_capabilities"`
}

type flagPatchReq struct {
	Enabled bool   `json:"enabled"`
	Reason  string `json:"reason"`
}

type moduleToggleReq struct {
	Enabled bool   `json:"enabled"`
	Reason  string `json:"reason"`
}

type opsJobActionReq struct {
	Reason string `json:"reason"`
}

type channelConfigPatchReq struct {
	Name    *string         `json:"name"`
	Enabled *bool           `json:"enabled"`
	Config  json.RawMessage `json:"config"`
	Secrets json.RawMessage `json:"secrets"`
}

type cpaSettingsReq struct {
	ManagementToken string `json:"management_token"`
	ManagementBase  string `json:"management_base"`
}

type cfSettingsReq struct {
	AccountID string `json:"account_id"`
	APIEmail  string `json:"api_email"`
	APIToken  string `json:"api_token"`
}

type aiSettingsReq struct {
	Enabled        *bool  `json:"enabled"`
	BaseURL        string `json:"base_url"`
	APIKey         string `json:"api_key"`
	Model          string `json:"model"`
	TimeoutSeconds int    `json:"timeout_seconds"`
}

type opsTargetReq struct {
	Name    string `json:"name"`
	Host    string `json:"host"`
	Port    int    `json:"port"`
	User    string `json:"user"`
	Enabled bool   `json:"enabled"`
}

var (
	validHostRe = regexp.MustCompile(`^(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$`)
	validUserRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
)

func validateTargetFields(host, user string, port int) error {
	host = strings.TrimSpace(host)
	user = strings.TrimSpace(user)
	if host == "" || user == "" {
		return fmt.Errorf("host/user 不能为空")
	}
	if ip := net.ParseIP(host); ip == nil {
		if !validHostRe.MatchString(host) {
			return fmt.Errorf("host 非法")
		}
	}
	if !validUserRe.MatchString(user) {
		return fmt.Errorf("user 非法")
	}
	if port <= 0 || port > 65535 {
		return fmt.Errorf("port 无效")
	}
	return nil
}

var rolePermissions = map[string][]string{
	"owner": {
		"records.read.self", "records.read.all",
		"records.delete.self", "records.delete.all",
		"records.export.self", "records.export.all",
		"settings.flags.read", "settings.flags.write",
		"channels.read", "channels.write", "channels.test",
		"audit.read", "ops.read", "ops.cancel", "ops.retry",
	},
	"admin": {
		"records.read.self", "records.delete.self", "records.export.self",
		"settings.flags.read", "channels.read", "audit.read", "ops.read", "ops.cancel", "ops.retry",
	},
	"viewer": {
		"records.read.self",
	},
}

func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
	return &WebServer{
		db:        db,
		dbPath:    dbPath,
		baseDir:   baseDir,
		finance:   finance,
		port:      port,
		username:  username,
		password:  password,
		secretKey: "ops-assistant-session-" + sessionKey,
		reloadFn:  reloadFn,
	}
}

func (s *WebServer) generateToken(username string) string {
	exp := time.Now().Add(7 * 24 * time.Hour).Unix()
	payload := fmt.Sprintf("%s|%d", username, exp)
	mac := hmac.New(sha256.New, []byte(s.secretKey))
	mac.Write([]byte(payload))
	sig := hex.EncodeToString(mac.Sum(nil))
	return fmt.Sprintf("%s|%s", payload, sig)
}

func (s *WebServer) validateToken(username, token string) bool {
	parts := strings.Split(token, "|")
	if len(parts) == 1 {
		// legacy token: HMAC(username)
		expected := s.generateLegacyToken(username)
		return hmac.Equal([]byte(expected), []byte(token))
	}
	if len(parts) != 3 {
		return false
	}
	if parts[0] != username {
		return false
	}
	exp, err := strconv.ParseInt(parts[1], 10, 64)
	if err != nil {
		return false
	}
	if time.Now().Unix() > exp {
		return false
	}
	payload := fmt.Sprintf("%s|%s", parts[0], parts[1])
	mac := hmac.New(sha256.New, []byte(s.secretKey))
	mac.Write([]byte(payload))
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(parts[2]))
}

func (s *WebServer) generateLegacyToken(username string) string {
	mac := hmac.New(sha256.New, []byte(s.secretKey))
	mac.Write([]byte(username))
	return hex.EncodeToString(mac.Sum(nil))
}

func (s *WebServer) buildCurrentUser(username string) *CurrentUser {
	role := "viewer"
	userID := int64(1)
	if username == s.username {
		role = "owner"
	}
	perms := map[string]bool{}
	permList := make([]string, 0)
	for _, p := range rolePermissions[role] {
		perms[p] = true
		permList = append(permList, p)
	}
	return &CurrentUser{Username: username, Role: role, UserID: userID, Permissions: perms, PermList: permList}
}

func (s *WebServer) getFlagMap() map[string]bool {
	res := map[string]bool{}
	var flags []models.FeatureFlag
	s.db.Find(&flags)
	for _, f := range flags {
		res[f.Key] = f.Enabled
	}
	return res
}

func (s *WebServer) flagEnabled(key string) bool {
	var ff models.FeatureFlag
	if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil {
		return false
	}
	return ff.Enabled
}

func (s *WebServer) hasPermission(u *CurrentUser, perm string) bool {
	if u == nil {
		return false
	}
	return u.Permissions[perm]
}

func (s *WebServer) requirePerm(c *gin.Context, u *CurrentUser, perm, msg string) bool {
	if s.hasPermission(u, perm) {
		return true
	}
	deny(c, msg)
	return false
}

func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra gin.H) {
	data := gin.H{"version": "v" + version.Version}
	if u != nil {
		data["username"] = u.Username
	}
	for k, v := range extra {
		data[k] = v
	}
	c.HTML(http.StatusOK, tpl, data)
}

func deny(c *gin.Context, msg string) {
	respondErr(c, http.StatusForbidden, ecode.ErrPermissionDenied, msg)
}

func currentUser(c *gin.Context) *CurrentUser {
	if v, ok := c.Get("currentUser"); ok {
		if u, ok2 := v.(*CurrentUser); ok2 {
			return u
		}
	}
	return nil
}

func (s *WebServer) authRequired() gin.HandlerFunc {
	return func(c *gin.Context) {
		username, _ := c.Cookie(cookieUserNew)
		token, _ := c.Cookie(cookieTokenNew)

		legacy := false
		if username == "" || token == "" {
			username, _ = c.Cookie(cookieUserOld)
			token, _ = c.Cookie(cookieTokenOld)
			legacy = username != "" && token != ""
		}

		if username == "" || token == "" || !s.validateToken(username, token) {
			path := c.Request.URL.Path
			if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" {
				respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录")
			} else {
				c.Redirect(http.StatusFound, "/login")
			}
			c.Abort()
			return
		}

		if legacy {
			maxAge := 7 * 24 * 3600
			c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true)
			c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true)
		} else if strings.Contains(token, "|") {
			// refresh exp for new token format
			maxAge := 7 * 24 * 3600
			fresh := s.generateToken(username)
			c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true)
			c.SetCookie(cookieTokenNew, fresh, maxAge, "/", "", false, true)
		}

		c.Set("currentUser", s.buildCurrentUser(username))
		c.Next()
	}
}

func (s *WebServer) writeAudit(actor int64, action, targetType, targetID, before, after, note string) {
	_ = s.db.Create(&models.AuditLog{
		ActorID:    actor,
		Action:     action,
		TargetType: targetType,
		TargetID:   targetID,
		BeforeJSON: before,
		AfterJSON:  after,
		Note:       note,
	}).Error
}

func (s *WebServer) writeAuditResult(actor int64, action, targetType, targetID, before, after, note, result string) {
	finalNote := strings.TrimSpace(note)
	if strings.TrimSpace(result) != "" {
		if finalNote == "" {
			finalNote = "result=" + result
		} else {
			finalNote = finalNote + " | result=" + result
		}
	}
	s.writeAudit(actor, action, targetType, targetID, before, after, finalNote)
}

func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) {
	auth.GET("/api/v1/me", s.handleMe)
	auth.GET("/api/v1/records", s.handleRecordsV1)
	auth.POST("/api/v1/records/:id/delete", s.handleDeleteV1)
	auth.GET("/api/v1/export", s.handleExportV1)
	auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList)
	auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch)
	// CPA settings
	auth.GET("/api/v1/admin/cpa/settings", s.handleCPASettingsGet)
	auth.PATCH("/api/v1/admin/cpa/settings", s.handleCPASettingsPatch)
	// Cloudflare settings
	auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet)
	auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch)
	// AI settings
	auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet)
	auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch)
	// Ops targets
	auth.GET("/api/v1/admin/ops/targets", s.handleOpsTargetsList)
	auth.POST("/api/v1/admin/ops/targets", s.handleOpsTargetsCreate)
	auth.PATCH("/api/v1/admin/ops/targets/:id", s.handleOpsTargetsPatch)

	auth.GET("/api/v1/admin/channels", s.handleChannelsList)
	auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch)
	auth.POST("/api/v1/admin/channels/:platform/publish", s.handleChannelPublish)
	auth.POST("/api/v1/admin/channels/reload", s.handleChannelReload)
	auth.POST("/api/v1/admin/channels/disable-all", s.handleChannelDisableAll)
	auth.POST("/api/v1/admin/channels/:platform/enable", s.handleChannelEnable)
	auth.POST("/api/v1/admin/channels/:platform/disable", s.handleChannelDisable)
	auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest)
	auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply)
	auth.GET("/api/v1/admin/audit", s.handleAuditList)
	auth.GET("/api/v1/admin/legacy/usage", s.handleLegacyUsage)
	auth.GET("/api/v1/admin/legacy/trend", s.handleLegacyTrend)
	auth.GET("/api/v1/admin/legacy/readiness", s.handleLegacyReadiness)
	auth.GET("/api/v1/modules", s.handleModulesList)
	auth.POST("/api/v1/modules/:module/toggle", s.handleModuleToggle)
	auth.GET("/api/v1/dashboard/overview", s.handleDashboardOverview)
	auth.GET("/api/v1/dashboard/summary", s.handleDashboardSummary)
	auth.GET("/api/v1/ops/jobs", s.handleOpsJobs)
	auth.GET("/api/v1/ops/jobs/request/:requestID", s.handleOpsJobsByRequestID)
	auth.GET("/api/v1/ops/jobs/:id", s.handleOpsJobDetail)
	auth.POST("/api/v1/ops/jobs/:id/cancel", s.handleOpsJobCancel)
	auth.POST("/api/v1/ops/jobs/:id/retry", s.handleOpsJobRetry)
}

func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) {
	// 兼容老前端调用，统一复用 v1 handler（兼容层）
	//
	// 废弃计划（仅文档约束，当前不删）：
	// 1) 新功能与新页面只允许使用 /api/v1/*
	// 2) 当确认无旧调用后，再移除以下旧路由映射
	// 3) 每次版本发布前，优先检查是否仍存在对旧路由的引用
	auth.GET("/api/records", s.handleLegacyRecords)
	auth.POST("/delete/:id", s.handleLegacyDelete)
	auth.GET("/export", s.handleLegacyExport)
}

func (s *WebServer) writeLegacyAccess(c *gin.Context, route string) {
	u := currentUser(c)
	uid := int64(0)
	if u != nil {
		uid = u.UserID
	}
	note := fmt.Sprintf("legacy route=%s method=%s path=%s ua=%s", route, c.Request.Method, c.Request.URL.Path, c.Request.UserAgent())
	s.writeAuditResult(uid, "legacy.route.access", "route", route, "", "", note, "success")
}

func (s *WebServer) markLegacyDeprecated(c *gin.Context, replacement string) {
	c.Header("X-API-Deprecated", "true")
	c.Header("X-API-Replacement", replacement)
	c.Header("Warning", fmt.Sprintf(`299 - "legacy API deprecated, use %s"`, replacement))
}

func (s *WebServer) handleLegacyRecords(c *gin.Context) {
	s.writeLegacyAccess(c, "/api/records")
	s.markLegacyDeprecated(c, "/api/v1/records")
	s.handleRecordsV1(c)
}

func (s *WebServer) handleLegacyDelete(c *gin.Context) {
	s.writeLegacyAccess(c, "/delete/:id")
	s.markLegacyDeprecated(c, "/api/v1/records/:id/delete")
	s.handleDeleteV1(c)
}

func (s *WebServer) handleLegacyExport(c *gin.Context) {
	s.writeLegacyAccess(c, "/export")
	s.markLegacyDeprecated(c, "/api/v1/export")
	s.handleExportV1(c)
}

func (s *WebServer) RegisterRoutes(r *gin.Engine) {
	r.LoadHTMLGlob("templates/*")

	r.GET("/login", s.handleLoginPage)
	r.POST("/login", s.handleLogin)
	r.GET("/logout", s.handleLogout)
	r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })

	auth := r.Group("/")
	auth.Use(s.authRequired())
	{
		auth.GET("/", s.handleIndex)
		auth.GET("/channels", s.handleChannelsPage)
		auth.GET("/audit", s.handleAuditPage)
		auth.GET("/ops", s.handleOpsPage)
		auth.GET("/cpa", s.handleCPASettingsPage)
		auth.GET("/cf", s.handleCFSettingsPage)
		// AI 配置页临时隐藏（保留后端接口）

		s.registerAPIV1Routes(auth)
		s.registerLegacyCompatRoutes(auth)
	}
}

func (s *WebServer) Start() {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(gin.Recovery())
	r.Use(gin.Logger())
	s.RegisterRoutes(r)

	logAddr := fmt.Sprintf(":%d", s.port)
	fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr)
	if err := r.Run(logAddr); err != nil {
		fmt.Printf("❌ Web服务启动失败: %v\n", err)
	}
}

func (s *WebServer) handleLoginPage(c *gin.Context) {
	username, _ := c.Cookie(cookieUserNew)
	token, _ := c.Cookie(cookieTokenNew)
	if username == "" || token == "" {
		username, _ = c.Cookie(cookieUserOld)
		token, _ = c.Cookie(cookieTokenOld)
	}
	if username != "" && token != "" && s.validateToken(username, token) {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "login.html", nil, gin.H{"error": ""})
}

func (s *WebServer) handleLogin(c *gin.Context) {
	username := c.PostForm("username")
	password := c.PostForm("password")

	if username == s.username && password == s.password {
		token := s.generateToken(username)
		maxAge := 7 * 24 * 3600
		c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true)
		c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true)
		c.SetCookie(cookieUserOld, "", -1, "/", "", false, true)
		c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true)
		u := s.buildCurrentUser(username)
		s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success")
		c.Redirect(http.StatusFound, "/")
		return
	}

	s.writeAuditResult(0, "auth.login.failed", "user", username, "", "", "用户名或密码错误", "failed")
	s.renderPage(c, "login.html", nil, gin.H{"error": "用户名或密码错误"})
}

func (s *WebServer) handleLogout(c *gin.Context) {
	u := currentUser(c)
	if u != nil {
		s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success")
	}
	c.SetCookie(cookieUserNew, "", -1, "/", "", false, true)
	c.SetCookie(cookieTokenNew, "", -1, "/", "", false, true)
	c.SetCookie(cookieUserOld, "", -1, "/", "", false, true)
	c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true)
	c.Redirect(http.StatusFound, "/login")
}

func (s *WebServer) handleIndex(c *gin.Context) {
	u := currentUser(c)
	s.renderPage(c, "index.html", u, nil)
}

func (s *WebServer) handleChannelsPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "channels.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "channels.html", u, nil)
}

func (s *WebServer) handleAuditPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "audit.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "audit.html", u, nil)
}

func (s *WebServer) handleOpsPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "ops.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "ops.html", u, nil)
}

func (s *WebServer) handleCPASettingsPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "settings.flags.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "cpa_settings.html", u, nil)
}

func (s *WebServer) handleCFSettingsPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "settings.flags.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "cf_settings.html", u, nil)
}

func (s *WebServer) handleAISettingsPage(c *gin.Context) {
	u := currentUser(c)
	if u == nil || !s.hasPermission(u, "settings.flags.read") {
		c.Redirect(http.StatusFound, "/")
		return
	}
	s.renderPage(c, "ai_settings.html", u, nil)
}

func (s *WebServer) handleCPASettingsGet(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	keys := []string{"cpa_management_token", "cpa_management_base"}
	out := map[string]string{}
	for _, k := range keys {
		var sset models.AppSetting
		if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
			out[k] = sset.Value
		} else {
			out[k] = ""
		}
	}
	// do not expose secret token
	if v := strings.TrimSpace(out["cpa_management_token"]); v != "" {
		out["cpa_management_token"] = "***"
	}
	respondOK(c, "ok", gin.H{"settings": out})
}

func (s *WebServer) handleCPASettingsPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	var req cpaSettingsReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	// management_token
	key := "cpa_management_token"
	var sset models.AppSetting
	if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
		sset = models.AppSetting{Key: key, Value: req.ManagementToken, UpdatedBy: u.UserID}
		if err := s.db.Create(&sset).Error; err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
			return
		}
	} else {
		old := sset.Value
		sset.Value = req.ManagementToken
		sset.UpdatedBy = u.UserID
		if err := s.db.Save(&sset).Error; err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
			return
		}
		_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementToken != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
	}

	// management_base
	key = "cpa_management_base"
	var ssetBase models.AppSetting
	if err := s.db.Where("key = ?", key).First(&ssetBase).Error; err != nil {
		ssetBase = models.AppSetting{Key: key, Value: req.ManagementBase, UpdatedBy: u.UserID}
		if err := s.db.Create(&ssetBase).Error; err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
			return
		}
	} else {
		old := ssetBase.Value
		ssetBase.Value = req.ManagementBase
		ssetBase.UpdatedBy = u.UserID
		if err := s.db.Save(&ssetBase).Error; err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
			return
		}
		_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
	}
	
	s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success")
	respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}})
}

func (s *WebServer) handleCFSettingsGet(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	keys := []string{"cf_account_id", "cf_api_email", "cf_api_token"}
	out := map[string]string{}
	for _, k := range keys {
		var sset models.AppSetting
		if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
			out[k] = sset.Value
		} else {
			out[k] = ""
		}
	}
	respondOK(c, "ok", gin.H{"settings": out})
}

func (s *WebServer) handleCFSettingsPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	var req cfSettingsReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	// account id
	if req.AccountID != "" {
		key := "cf_account_id"
		var sset models.AppSetting
		if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
			sset = models.AppSetting{Key: key, Value: req.AccountID, UpdatedBy: u.UserID}
			_ = s.db.Create(&sset).Error
		} else {
			sset.Value = req.AccountID
			sset.UpdatedBy = u.UserID
			_ = s.db.Save(&sset).Error
		}
	}
	// api email
	if req.APIEmail != "" {
		key := "cf_api_email"
		var sset models.AppSetting
		if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
			sset = models.AppSetting{Key: key, Value: req.APIEmail, UpdatedBy: u.UserID}
			_ = s.db.Create(&sset).Error
		} else {
			sset.Value = req.APIEmail
			sset.UpdatedBy = u.UserID
			_ = s.db.Save(&sset).Error
		}
	}
	// api token
	if req.APIToken != "" {
		key := "cf_api_token"
		var sset models.AppSetting
		if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
			sset = models.AppSetting{Key: key, Value: req.APIToken, UpdatedBy: u.UserID}
			_ = s.db.Create(&sset).Error
		} else {
			sset.Value = req.APIToken
			sset.UpdatedBy = u.UserID
			_ = s.db.Save(&sset).Error
		}
	}
	respondOK(c, "success", gin.H{"ok": true})
}

func (s *WebServer) handleAISettingsGet(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	keys := []string{"ai_enabled", "ai_base_url", "ai_api_key", "ai_model", "ai_timeout_seconds"}
	out := map[string]string{}
	for _, k := range keys {
		var sset models.AppSetting
		if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
			out[k] = sset.Value
		} else {
			out[k] = ""
		}
	}
	respondOK(c, "ok", gin.H{"settings": out})
}

func (s *WebServer) handleAISettingsPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	var req aiSettingsReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	setKV := func(key, val string) {
		var sset models.AppSetting
		if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
			sset = models.AppSetting{Key: key, Value: val, UpdatedBy: u.UserID}
			_ = s.db.Create(&sset).Error
			return
		}
		sset.Value = val
		sset.UpdatedBy = u.UserID
		_ = s.db.Save(&sset).Error
	}
	if req.Enabled != nil {
		if *req.Enabled {
			setKV("ai_enabled", "true")
		} else {
			setKV("ai_enabled", "false")
		}
	}
	if strings.TrimSpace(req.BaseURL) != "" {
		setKV("ai_base_url", strings.TrimSpace(req.BaseURL))
	}
	if strings.TrimSpace(req.APIKey) != "" {
		setKV("ai_api_key", strings.TrimSpace(req.APIKey))
	}
	if strings.TrimSpace(req.Model) != "" {
		setKV("ai_model", strings.TrimSpace(req.Model))
	}
	if req.TimeoutSeconds > 0 {
		setKV("ai_timeout_seconds", strconv.Itoa(req.TimeoutSeconds))
	}
	s.writeAuditResult(u.UserID, "ai.settings.update", "settings", "ai", "", "", "", "success")
	respondOK(c, "success", gin.H{"ok": true})
}

func (s *WebServer) handleOpsTargetsList(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	var items []models.OpsTarget
	s.db.Order("name asc").Find(&items)
	respondOK(c, "ok", gin.H{"targets": items})
}

func (s *WebServer) handleOpsTargetsCreate(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	var req opsTargetReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.User) == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "name/host/user 不能为空")
		return
	}
	if req.Port == 0 {
		req.Port = 22
	}
	if err := validateTargetFields(req.Host, req.User, req.Port); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
		return
	}
	item := models.OpsTarget{Name: strings.TrimSpace(req.Name), Host: strings.TrimSpace(req.Host), Port: req.Port, User: strings.TrimSpace(req.User), Enabled: req.Enabled}
	if err := s.db.Create(&item).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "创建失败")
		return
	}
	s.writeAuditResult(u.UserID, "ops_target.create", "ops_target", item.Name, "", "", "", "success")
	respondOK(c, "success", gin.H{"target": item})
}

func (s *WebServer) handleOpsTargetsPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	id, _ := strconv.Atoi(strings.TrimSpace(c.Param("id")))
	if id <= 0 {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id 无效")
		return
	}
	var req opsTargetReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	var item models.OpsTarget
	if err := s.db.First(&item, id).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "目标不存在")
		return
	}
	if strings.TrimSpace(req.Name) != "" {
		item.Name = strings.TrimSpace(req.Name)
	}
	if strings.TrimSpace(req.Host) != "" {
		item.Host = strings.TrimSpace(req.Host)
	}
	if strings.TrimSpace(req.User) != "" {
		item.User = strings.TrimSpace(req.User)
	}
	if req.Port != 0 {
		item.Port = req.Port
	}
	item.Enabled = req.Enabled
	if err := validateTargetFields(item.Host, item.User, item.Port); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
		return
	}
	if err := s.db.Save(&item).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败")
		return
	}
	s.writeAuditResult(u.UserID, "ops_target.update", "ops_target", item.Name, "", "", "", "success")
	respondOK(c, "success", gin.H{"target": item})
}

func (s *WebServer) handleMe(c *gin.Context) {
	u := currentUser(c)
	if u == nil {
		respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录")
		return
	}

	flags := s.getFlagMap()
	caps := map[string]bool{
		"can_read_self":     s.hasPermission(u, "records.read.self"),
		"can_read_all":      s.hasPermission(u, "records.read.all") && flags["allow_cross_user_read"],
		"can_delete_self":   s.hasPermission(u, "records.delete.self"),
		"can_delete_all":    s.hasPermission(u, "records.delete.all") && flags["allow_cross_user_delete"],
		"can_export_self":   s.hasPermission(u, "records.export.self"),
		"can_export_all":    s.hasPermission(u, "records.export.all") && flags["allow_export_all_users"],
		"can_view_flags":    s.hasPermission(u, "settings.flags.read"),
		"can_edit_flags":    s.hasPermission(u, "settings.flags.write"),
		"can_view_channels": s.hasPermission(u, "channels.read"),
		"can_edit_channels": s.hasPermission(u, "channels.write"),
		"can_test_channels": s.hasPermission(u, "channels.test"),
		"can_view_audit":    s.hasPermission(u, "audit.read"),
		"can_view_ops":      s.hasPermission(u, "ops.read"),
		"can_cancel_ops":    s.hasPermission(u, "ops.cancel"),
		"can_retry_ops":     s.hasPermission(u, "ops.retry"),
	}

	u.Flags = flags
	u.Caps = caps
	respondOK(c, "ok", u)
}

func (s *WebServer) handleRecordsV1(c *gin.Context) {
	u := currentUser(c)
	if !s.hasPermission(u, "records.read.self") {
		s.writeAuditResult(u.UserID, "record.list.self", "transaction", "*", "", "", "无 records.read.self 权限", "denied")
		deny(c, "无 records.read.self 权限")
		return
	}

	scope := c.DefaultQuery("scope", "self")
	q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false)
	action := "record.list.self"
	note := ""

	if scope == "all" {
		action = "record.list.all"
		if !s.hasPermission(u, "records.read.all") {
			s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.read.all 权限", "denied")
			deny(c, "无 records.read.all 权限")
			return
		}
		if !s.flagEnabled("allow_cross_user_read") {
			s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_cross_user_read 未开启", "denied")
			deny(c, "策略开关 allow_cross_user_read 未开启")
			return
		}
	} else {
		q = q.Where("user_id = ?", u.UserID)
	}

	var items []models.Transaction
	q.Order("id desc").Limit(100).Find(&items)

	type txResponse struct {
		ID       uint    `json:"id"`
		UserID   int64   `json:"user_id"`
		Amount   float64 `json:"amount"`
		Category string  `json:"category"`
		Note     string  `json:"note"`
		Date     string  `json:"date"`
	}

	resp := make([]txResponse, len(items))
	for i, item := range items {
		resp[i] = txResponse{ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date}
	}
	note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp))
	s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success")
	respondOK(c, "ok", gin.H{"records": resp})
}

func (s *WebServer) handleDeleteV1(c *gin.Context) {
	u := currentUser(c)
	idStr := c.Param("id")
	id, err := strconv.ParseUint(idStr, 10, 64)
	if err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "无效的ID")
		return
	}

	var tx models.Transaction
	if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "记录不存在或已删除")
		return
	}

	action := "record.delete.self"
	if tx.UserID == u.UserID {
		if !s.hasPermission(u, "records.delete.self") {
			s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.self 权限", "denied")
			deny(c, "无 records.delete.self 权限")
			return
		}
	} else {
		action = "record.delete.all"
		if !s.hasPermission(u, "records.delete.all") {
			s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.all 权限", "denied")
			deny(c, "无 records.delete.all 权限")
			return
		}
		if !s.flagEnabled("allow_cross_user_delete") {
			s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "策略开关 allow_cross_user_delete 未开启", "denied")
			deny(c, "策略开关 allow_cross_user_delete 未开启")
			return
		}
	}

	result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true)
	if result.Error != nil {
		s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed")
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "删除失败")
		return
	}
	s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success")
	respondOK(c, "success", gin.H{"id": id})
}

func (s *WebServer) handleExportV1(c *gin.Context) {
	u := currentUser(c)
	scope := c.DefaultQuery("scope", "self")
	action := "record.export.self"

	q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false)
	if scope == "all" {
		action = "record.export.all"
		if !s.hasPermission(u, "records.export.all") {
			s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.all 权限", "denied")
			deny(c, "无 records.export.all 权限")
			return
		}
		if !s.flagEnabled("allow_export_all_users") {
			s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_export_all_users 未开启", "denied")
			deny(c, "策略开关 allow_export_all_users 未开启")
			return
		}
	} else {
		if !s.hasPermission(u, "records.export.self") {
			s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.self 权限", "denied")
			deny(c, "无 records.export.self 权限")
			return
		}
		q = q.Where("user_id = ?", u.UserID)
	}

	var items []models.Transaction
	q.Order("date asc, id asc").Find(&items)

	now := time.Now().Format("20060102")
	filename := fmt.Sprintf("ops_assistant_%s.csv", now)
	c.Header("Content-Type", "text/csv; charset=utf-8")
	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))

	c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
	c.Writer.WriteString("ID,用户ID,日期,分类,金额(元),备注\n")
	for _, item := range items {
		line := fmt.Sprintf("%d,%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.UserID, item.Date, item.Category, item.AmountYuan(), item.Note)
		c.Writer.WriteString(line)
	}
	s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", fmt.Sprintf("scope=%s,count=%d", scope, len(items)), "success")
}

func (s *WebServer) handleFlagsList(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	var flags []models.FeatureFlag
	s.db.Order("key asc").Find(&flags)
	respondOK(c, "ok", gin.H{"flags": flags})
}

func (s *WebServer) handleFlagPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.hasPermission(u, "settings.flags.write") {
		s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", c.Param("key"), "", "", "无 settings.flags.write 权限", "denied")
		deny(c, "无 settings.flags.write 权限")
		return
	}

	key := c.Param("key")
	var req flagPatchReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}

	var ff models.FeatureFlag
	if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "开关不存在")
		return
	}
	if ff.RequireReason && strings.TrimSpace(req.Reason) == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "该开关修改必须提供 reason")
		return
	}

	before := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled)
	old := ff.Enabled
	ff.Enabled = req.Enabled
	ff.UpdatedBy = u.UserID
	if err := s.db.Save(&ff).Error; err != nil {
		s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed")
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败")
		return
	}

	after := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled)
	h := models.FeatureFlagHistory{FlagKey: key, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}
	_ = s.db.Create(&h).Error
	s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success")

	respondOK(c, "success", gin.H{"key": key, "old": old, "new": req.Enabled})
}

func sanitizeJSON(raw string) string {
	if strings.TrimSpace(raw) == "" {
		return "{}"
	}
	var m map[string]any
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return "{}"
	}
	for k := range m {
		lk := strings.ToLower(k)
		if strings.Contains(lk, "token") || strings.Contains(lk, "secret") || strings.Contains(lk, "key") || strings.Contains(lk, "password") {
			m[k] = "***"
		}
	}
	b, _ := json.Marshal(m)
	return string(b)
}

func isMaskedSecretsPayload(raw json.RawMessage) bool {
	if len(raw) == 0 {
		return false
	}
	var v any
	if err := json.Unmarshal(raw, &v); err != nil {
		return false
	}
	var walk func(any) bool
	walk = func(x any) bool {
		switch t := x.(type) {
		case map[string]any:
			if len(t) == 0 {
				return false
			}
			allMasked := true
			for _, vv := range t {
				if !walk(vv) {
					allMasked = false
					break
				}
			}
			return allMasked
		case []any:
			if len(t) == 0 {
				return false
			}
			for _, vv := range t {
				if !walk(vv) {
					return false
				}
			}
			return true
		case string:
			return strings.TrimSpace(t) == "***"
		default:
			return false
		}
	}
	return walk(v)
}

func (s *WebServer) handleChannelsList(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.read", "无 channels.read 权限") {
		return
	}

	var items []models.ChannelConfig
	s.db.Order("platform asc").Find(&items)

	type out struct {
		ID              uint       `json:"id"`
		Platform        string     `json:"platform"`
		Name            string     `json:"name"`
		Enabled         bool       `json:"enabled"`
		Status          string     `json:"status"`
		ConfigJSON      string     `json:"config_json"`
		DraftConfigJSON string     `json:"draft_config_json"`
		Secrets         string     `json:"secrets"`
		DraftSecrets    string     `json:"draft_secrets"`
		HasDraft        bool       `json:"has_draft"`
		PublishedAt     *time.Time `json:"published_at"`
		LastCheck       *time.Time `json:"last_check_at"`
		UpdatedAt       time.Time  `json:"updated_at"`
	}

	resp := make([]out, 0, len(items))
	for _, it := range items {
		sec := channel.MaybeDecryptPublic(it.SecretJSON)
		draftSec := channel.MaybeDecryptPublic(it.DraftSecretJSON)
		resp = append(resp, out{
			ID:              it.ID,
			Platform:        it.Platform,
			Name:            it.Name,
			Enabled:         it.Enabled,
			Status:          it.Status,
			ConfigJSON:      it.ConfigJSON,
			DraftConfigJSON: it.DraftConfigJSON,
			Secrets:         sanitizeJSON(sec),
			DraftSecrets:    sanitizeJSON(draftSec),
			HasDraft:        strings.TrimSpace(it.DraftConfigJSON) != "" || strings.TrimSpace(it.DraftSecretJSON) != "",
			PublishedAt:     it.PublishedAt,
			LastCheck:       it.LastCheck,
			UpdatedAt:       it.UpdatedAt,
		})
	}
	respondOK(c, "ok", gin.H{"channels": resp})
}

func (s *WebServer) handleChannelPatch(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	platform := c.Param("platform")
	var req channelConfigPatchReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}

	var row models.ChannelConfig
	if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在")
		return
	}

	before := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`,
		sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)))

	if req.Name != nil {
		row.Name = strings.TrimSpace(*req.Name)
	}
	if req.Enabled != nil {
		row.Enabled = *req.Enabled
	}
	if len(req.Config) > 0 {
		row.DraftConfigJSON = string(req.Config)
	}
	if len(req.Secrets) > 0 {
		if isMaskedSecretsPayload(req.Secrets) {
			// 前端脱敏占位符（***）不应覆盖真实密钥
		} else {
			row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets))
		}
	}

	if err := s.db.Save(&row).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
		return
	}

	after := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`,
		sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)))
	s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "")

	respondOK(c, "success", gin.H{"mode": "draft"})
}

func (s *WebServer) handleChannelPublish(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	platform := c.Param("platform")
	var row models.ChannelConfig
	if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在")
		return
	}

	before := fmt.Sprintf(`{"config":%s,"secrets":%s}`,
		sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)))

	if strings.TrimSpace(row.DraftConfigJSON) != "" {
		row.ConfigJSON = row.DraftConfigJSON
	}
	if strings.TrimSpace(row.DraftSecretJSON) != "" {
		row.SecretJSON = row.DraftSecretJSON
	}
	now := time.Now()
	row.PublishedAt = &now
	row.UpdatedBy = u.UserID
	if err := s.db.Save(&row).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "发布失败")
		return
	}

	after := fmt.Sprintf(`{"config":%s,"secrets":%s}`,
		sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)))
	s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "")

	respondOK(c, "success", gin.H{"published_at": now})
}

func (s *WebServer) handleChannelReload(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	if s.reloadFn == nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置")
		return
	}

	detail, err := s.reloadFn()
	if err != nil {
		s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error())
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, err.Error())
		return
	}
	s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail)
	respondOK(c, "success", gin.H{"detail": detail})
}

func (s *WebServer) handleChannelEnable(c *gin.Context) {
	s.handleChannelToggle(c, true)
}

func (s *WebServer) handleChannelDisable(c *gin.Context) {
	s.handleChannelToggle(c, false)
}

func (s *WebServer) handleChannelDisableAll(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	res := s.db.Model(&models.ChannelConfig{}).Where("enabled = ?", true).Updates(map[string]any{
		"enabled":    false,
		"status":     "disabled",
		"updated_by": u.UserID,
	})
	if res.Error != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "批量关闭失败")
		return
	}
	s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "")
	respondOK(c, "success", gin.H{"affected": res.RowsAffected})
}

func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	platform := c.Param("platform")
	var row models.ChannelConfig
	if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在")
		return
	}

	before := fmt.Sprintf(`{"enabled":%v}`, row.Enabled)
	row.Enabled = enable
	if !enable {
		row.Status = "disabled"
	}
	row.UpdatedBy = u.UserID
	if err := s.db.Save(&row).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败")
		return
	}
	after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled)
	action := "channel_disable"
	if enable {
		action = "channel_enable"
	}
	s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "")
	respondOK(c, "success", gin.H{"enabled": row.Enabled, "platform": row.Platform})
}

func (s *WebServer) handleChannelTest(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.test", "无 channels.test 权限") {
		return
	}

	platform := c.Param("platform")
	var row models.ChannelConfig
	if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在")
		return
	}

	if strings.TrimSpace(row.DraftConfigJSON) != "" {
		row.ConfigJSON = row.DraftConfigJSON
	}
	if strings.TrimSpace(row.DraftSecretJSON) != "" {
		row.SecretJSON = row.DraftSecretJSON
	}

	now := time.Now()
	status, detail := channel.TestChannelConnectivity(context.Background(), row)
	row.LastCheck = &now
	row.Status = status
	if err := s.db.Save(&row).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "测试写入失败")
		return
	}

	s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test")
	respondOK(c, "ok", gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now})
}

func (s *WebServer) handleChannelApply(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
		return
	}

	platform := c.Param("platform")
	var req channelConfigPatchReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误(stage=patch,committed=false)")
		return
	}

	var row models.ChannelConfig
	if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在(stage=patch,committed=false)")
		return
	}

	beforeEnabled := row.Enabled
	beforeConfig := row.ConfigJSON
	beforeDraftConfig := row.DraftConfigJSON
	beforeSecret := channel.MaybeDecryptPublic(row.SecretJSON)
	beforeDraftSecret := channel.MaybeDecryptPublic(row.DraftSecretJSON)

	if req.Name != nil {
		row.Name = strings.TrimSpace(*req.Name)
	}
	if req.Enabled != nil {
		row.Enabled = *req.Enabled
	}
	if len(req.Config) > 0 {
		row.DraftConfigJSON = string(req.Config)
	}
	if len(req.Secrets) > 0 {
		if isMaskedSecretsPayload(req.Secrets) {
			// 前端脱敏占位符（***）不应覆盖真实密钥
		} else {
			row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets))
		}
	}
	if !row.Enabled {
		row.Status = "disabled"
	}
	if strings.TrimSpace(row.DraftConfigJSON) != "" {
		row.ConfigJSON = row.DraftConfigJSON
	}
	if strings.TrimSpace(row.DraftSecretJSON) != "" {
		row.SecretJSON = row.DraftSecretJSON
	}
	publishAt := time.Now()
	row.PublishedAt = &publishAt
	row.UpdatedBy = u.UserID

	if err := s.db.Save(&row).Error; err != nil {
		s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error())
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存并发布失败(stage=publish,committed=false)")
		return
	}

	before := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`,
		beforeEnabled,
		sanitizeJSON(beforeConfig),
		sanitizeJSON(beforeDraftConfig),
		sanitizeJSON(beforeSecret),
		sanitizeJSON(beforeDraftSecret),
	)
	after := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`,
		row.Enabled,
		sanitizeJSON(row.ConfigJSON),
		sanitizeJSON(row.DraftConfigJSON),
		sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)),
		sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)),
	)

	if s.reloadFn == nil {
		s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置")
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置(stage=reload,committed=true)")
		return
	}
	detail, err := s.reloadFn()
	if err != nil {
		s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error())
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload失败(stage=reload,committed=true): "+err.Error())
		return
	}

	note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail)
	s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note)

	respondOK(c, "success", gin.H{
		"platform":     row.Platform,
		"published_at": publishAt,
		"detail":       detail,
	})
}

func (s *WebServer) handleAuditList(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") {
		return
	}

	action := strings.TrimSpace(c.Query("action"))
	targetType := strings.TrimSpace(c.Query("target_type"))
	result := strings.TrimSpace(c.Query("result"))
	actorID := strings.TrimSpace(c.Query("actor_id"))
	from := strings.TrimSpace(c.Query("from"))
	to := strings.TrimSpace(c.Query("to"))
	limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
	if limit <= 0 || limit > 500 {
		limit = 100
	}
	offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
	if offset < 0 {
		offset = 0
	}

	q := s.db.Model(&models.AuditLog{})
	if action != "" {
		q = q.Where("action = ?", action)
	}
	if targetType != "" {
		q = q.Where("target_type = ?", targetType)
	}
	if actorID != "" {
		if aid, err := strconv.ParseInt(actorID, 10, 64); err == nil {
			q = q.Where("actor_id = ?", aid)
		}
	}
	if from != "" {
		if t, err := time.Parse(time.RFC3339, from); err == nil {
			q = q.Where("created_at >= ?", t)
		}
	}
	if to != "" {
		if t, err := time.Parse(time.RFC3339, to); err == nil {
			q = q.Where("created_at <= ?", t)
		}
	}
	if result != "" {
		q = q.Where("note LIKE ?", "%result="+result+"%")
	}

	var logs []models.AuditLog
	if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询失败")
		return
	}
	respondOK(c, "ok", gin.H{"logs": logs})
}

func (s *WebServer) handleLegacyUsage(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") {
		return
	}

	type row struct {
		Route string `json:"route"`
		Count int64  `json:"count"`
	}
	routes := []string{"/api/records", "/delete/:id", "/export"}
	usage := make([]row, 0, len(routes))
	for _, rt := range routes {
		var cnt int64
		err := s.db.Model(&models.AuditLog{}).
			Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt).
			Count(&cnt).Error
		if err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy usage失败")
			return
		}
		usage = append(usage, row{Route: rt, Count: cnt})
	}

	var recent []models.AuditLog
	if err := s.db.Where("action = ? AND target_type = ?", "legacy.route.access", "route").Order("id desc").Limit(50).Find(&recent).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy recent失败")
		return
	}
	respondOK(c, "ok", gin.H{"summary": usage, "recent": recent})
}

func (s *WebServer) handleLegacyTrend(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") {
		return
	}
	days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
	if days <= 0 || days > 90 {
		days = 7
	}
	start := time.Now().AddDate(0, 0, -days+1)

	type point struct {
		Day   string `json:"day"`
		Count int64  `json:"count"`
	}
	type routeTrend struct {
		Route  string  `json:"route"`
		Points []point `json:"points"`
	}
	routes := []string{"/api/records", "/delete/:id", "/export"}
	trends := make([]routeTrend, 0, len(routes))

	for _, rt := range routes {
		pts := make([]point, 0, days)
		for i := 0; i < days; i++ {
			dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i)
			dayEnd := dayStart.Add(24 * time.Hour)
			var cnt int64
			err := s.db.Model(&models.AuditLog{}).
				Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt).
				Where("created_at >= ? AND created_at < ?", dayStart, dayEnd).
				Count(&cnt).Error
			if err != nil {
				respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy trend失败")
				return
			}
			pts = append(pts, point{Day: dayStart.Format("2006-01-02"), Count: cnt})
		}
		trends = append(trends, routeTrend{Route: rt, Points: pts})
	}
	respondOK(c, "ok", gin.H{"days": days, "from": start.Format(time.RFC3339), "trends": trends})
}

func (s *WebServer) handleLegacyReadiness(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") {
		return
	}
	days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
	if days <= 0 || days > 90 {
		days = 7
	}
	zeroDays, _ := strconv.Atoi(c.DefaultQuery("zero_days", "3"))
	if zeroDays <= 0 || zeroDays > 30 {
		zeroDays = 3
	}

	start := time.Now().AddDate(0, 0, -days+1)
	routes := []string{"/api/records", "/delete/:id", "/export"}
	routeTotals := map[string]int64{}
	zeroStreak := map[string]int{}
	windowTotal := int64(0)
	ready := true

	for _, rt := range routes {
		var total int64
		err := s.db.Model(&models.AuditLog{}).
			Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt).
			Where("created_at >= ?", start).
			Count(&total).Error
		if err != nil {
			respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败")
			return
		}
		routeTotals[rt] = total
		windowTotal += total

		streak := 0
		for i := days - 1; i >= 0; i-- {
			dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i)
			dayEnd := dayStart.Add(24 * time.Hour)
			var cnt int64
			err := s.db.Model(&models.AuditLog{}).
				Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt).
				Where("created_at >= ? AND created_at < ?", dayStart, dayEnd).
				Count(&cnt).Error
			if err != nil {
				respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败")
				return
			}
			if cnt == 0 {
				streak++
			} else {
				break
			}
		}
		zeroStreak[rt] = streak
		if streak < zeroDays {
			ready = false
		}
	}

	recommendation := "暂不建议下线 legacy 路由（未满足连续0调用阈值）"
	if ready {
		recommendation = "可考虑下线 legacy 路由（已满足连续0调用阈值）"
	}
	respondOK(c, "ok", gin.H{
		"days":                  days,
		"zero_days":             zeroDays,
		"window_total":          windowTotal,
		"route_totals":          routeTotals,
		"consecutive_zero_days": zeroStreak,
		"ready":                 ready,
		"recommendation":        recommendation,
	})
}

func (s *WebServer) handleModulesList(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
		return
	}
	type moduleItem struct {
		Module      string `json:"module"`
		DisplayName string `json:"display_name"`
		FlagKey     string `json:"flag_key"`
		Enabled     bool   `json:"enabled"`
	}
	items := []moduleItem{
		{Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"},
		{Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"},
		{Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"},
	}
	for i := range items {
		var ff models.FeatureFlag
		if err := s.db.Where("key = ?", items[i].FlagKey).First(&ff).Error; err == nil {
			items[i].Enabled = ff.Enabled
		}
	}
	respondOK(c, "ok", gin.H{"modules": items})
}

func (s *WebServer) handleModuleToggle(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
		return
	}
	module := strings.TrimSpace(strings.ToLower(c.Param("module")))
	flagKey := ""
	switch module {
	case "cpa":
		flagKey = "enable_module_cpa"
	case "cf":
		flagKey = "enable_module_cf"
	case "mail":
		flagKey = "enable_module_mail"
	default:
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module")
		return
	}

	var req moduleToggleReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	if strings.TrimSpace(req.Reason) == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "修改模块开关必须提供 reason")
		return
	}
	if module == "cpa" && !req.Enabled {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "禁止禁用关键模块 cpa")
		return
	}

	var ff models.FeatureFlag
	if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "模块开关不存在")
		return
	}
	old := ff.Enabled
	if old == req.Enabled {
		respondOK(c, "noop", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled})
		return
	}
	ff.Enabled = req.Enabled
	ff.UpdatedBy = u.UserID
	if err := s.db.Save(&ff).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败")
		return
	}
	_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: flagKey, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}).Error
	s.writeAuditResult(u.UserID, "module.toggle", "module", module, fmt.Sprintf(`{"enabled":%v}`, old), fmt.Sprintf(`{"enabled":%v}`, req.Enabled), req.Reason, "success")
	respondOK(c, "success", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled})
}

func (s *WebServer) handleDashboardOverview(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") {
		return
	}

	var jobs []models.OpsJob
	s.db.Order("id desc").Limit(30).Find(&jobs)
	statusCount := map[string]int{"pending": 0, "running": 0, "success": 0, "failed": 0, "cancelled": 0}
	for _, j := range jobs {
		statusCount[j.Status]++
	}

	type moduleItem struct {
		Module  string `json:"module"`
		Enabled bool   `json:"enabled"`
	}
	mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}}
	for i := range mods {
		flagKey := "enable_module_" + mods[i].Module
		var ff models.FeatureFlag
		if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err == nil {
			mods[i].Enabled = ff.Enabled
		}
	}

	var channels []models.ChannelConfig
	s.db.Order("platform asc").Find(&channels)
	channelOut := make([]gin.H, 0, len(channels))
	for _, ch := range channels {
		channelOut = append(channelOut, gin.H{"platform": ch.Platform, "enabled": ch.Enabled, "status": ch.Status})
	}

	respondOK(c, "ok", gin.H{"jobs": gin.H{"recent": jobs, "status_count": statusCount}, "modules": mods, "channels": channelOut})
}

func (s *WebServer) handleOpsJobs(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") {
		return
	}
	limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	status := strings.TrimSpace(c.Query("status"))
	target := strings.TrimSpace(c.Query("target"))
	runbook := strings.TrimSpace(c.Query("runbook"))
	requestID := strings.TrimSpace(c.Query("request_id"))
	operator := strings.TrimSpace(c.Query("operator"))
	riskLevel := strings.TrimSpace(c.Query("risk_level"))
	qtext := strings.TrimSpace(c.Query("q"))
	from := strings.TrimSpace(c.Query("from"))
	to := strings.TrimSpace(c.Query("to"))

	q := s.db.Model(&models.OpsJob{})
	if status != "" {
		q = q.Where("status = ?", status)
	}
	if target != "" {
		q = q.Where("target = ?", target)
	}
	if runbook != "" {
		q = q.Where("runbook = ?", runbook)
	}
	if requestID != "" {
		q = q.Where("request_id = ?", requestID)
	}
	if operator != "" {
		opid, err := strconv.ParseInt(operator, 10, 64)
		if err != nil {
			respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid operator")
			return
		}
		q = q.Where("operator = ?", opid)
	}
	if riskLevel != "" {
		q = q.Where("risk_level = ?", riskLevel)
	}
	if qtext != "" {
		like := "%" + qtext + "%"
		q = q.Where("command LIKE ? OR runbook LIKE ? OR target LIKE ? OR request_id LIKE ?", like, like, like, like)
	}
	if from != "" {
		t, err := time.Parse(time.RFC3339, from)
		if err != nil {
			respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid from, must be RFC3339")
			return
		}
		q = q.Where("created_at >= ?", t)
	}
	if to != "" {
		t, err := time.Parse(time.RFC3339, to)
		if err != nil {
			respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid to, must be RFC3339")
			return
		}
		q = q.Where("created_at <= ?", t)
	}

	var jobs []models.OpsJob
	if err := q.Order("id desc").Limit(limit).Find(&jobs).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败")
		return
	}
	respondOK(c, "ok", gin.H{"jobs": jobs, "filters": gin.H{"limit": limit, "status": status, "target": target, "runbook": runbook, "request_id": requestID, "operator": operator, "risk_level": riskLevel, "q": qtext, "from": from, "to": to}})
}

func (s *WebServer) handleDashboardSummary(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") {
		return
	}

	var total, running, failed, success int64
	s.db.Model(&models.OpsJob{}).Count(&total)
	s.db.Model(&models.OpsJob{}).Where("status = ?", "running").Count(&running)
	s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed)
	s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success)

	mods := map[string]bool{"cpa": false, "cf": false, "mail": false}
	for k := range mods {
		var ff models.FeatureFlag
		if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil {
			mods[k] = ff.Enabled
		}
	}

	respondOK(c, "ok", gin.H{
		"jobs":    gin.H{"total": total, "running": running, "failed": failed, "success": success},
		"modules": mods,
	})
}

func (s *WebServer) handleOpsJobsByRequestID(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") {
		return
	}
	requestID := strings.TrimSpace(c.Param("requestID"))
	if requestID == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "request id required")
		return
	}
	limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	var jobs []models.OpsJob
	if err := s.db.Where("request_id = ?", requestID).Order("id desc").Limit(limit).Find(&jobs).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败")
		return
	}
	var total int64
	if err := s.db.Model(&models.OpsJob{}).Where("request_id = ?", requestID).Count(&total).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs总数失败")
		return
	}
	respondOK(c, "ok", gin.H{"request_id": requestID, "total": total, "jobs": jobs})
}

func (s *WebServer) handleOpsJobDetail(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") {
		return
	}
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil || id <= 0 {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id")
		return
	}

	var job models.OpsJob
	if err := s.db.First(&job, id).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found")
		return
	}

	var steps []models.OpsJobStep
	if err := s.db.Where("job_id = ?", job.ID).Order("id asc").Find(&steps).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "query steps failed")
		return
	}

	stepStats := map[string]int{"running": 0, "success": 0, "failed": 0, "skipped": 0}
	var stepDurationMs int64
	for _, st := range steps {
		stepStats[st.Status]++
		if !st.StartedAt.IsZero() && !st.EndedAt.IsZero() {
			d := st.EndedAt.Sub(st.StartedAt).Milliseconds()
			if d > 0 {
				stepDurationMs += d
			}
		}
	}
	var jobDurationMs int64
	if !job.StartedAt.IsZero() && !job.EndedAt.IsZero() {
		d := job.EndedAt.Sub(job.StartedAt).Milliseconds()
		if d > 0 {
			jobDurationMs = d
		}
	}
	respondOK(c, "ok", gin.H{"job": job, "steps": steps, "step_stats": stepStats, "step_total": len(steps), "duration": gin.H{"job_ms": jobDurationMs, "steps_ms_sum": stepDurationMs}})
}

func (s *WebServer) handleOpsJobCancel(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.cancel", "无 ops.cancel 权限") {
		return
	}
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil || id <= 0 {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id")
		return
	}

	var req opsJobActionReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	reason := strings.TrimSpace(req.Reason)
	if reason == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cancel 必须提供 reason")
		return
	}

	var job models.OpsJob
	if err := s.db.First(&job, id).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found")
		return
	}
	if job.Status == "success" || job.Status == "failed" || job.Status == "cancelled" {
		respondOK(c, "noop", gin.H{"id": job.ID, "job_status": job.Status, "reason": "job already finished"})
		return
	}
	if job.Status != "pending" && job.Status != "running" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "job status not cancellable")
		return
	}
	cancelled := runbook.CancelJob(job.ID)
	job.Status = "cancelled"
	job.CancelNote = reason
	job.EndedAt = time.Now()
	if err := s.db.Save(&job).Error; err != nil {
		respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "cancel failed")
		return
	}
	s.writeAuditResult(u.UserID, "ops.job.cancel", "ops_job", strconv.Itoa(int(job.ID)), "", "", reason, "success")
	respondOK(c, "cancelled", gin.H{"id": job.ID, "reason": reason, "signal_sent": cancelled})
}

func (s *WebServer) handleOpsJobRetry(c *gin.Context) {
	u := currentUser(c)
	if !s.requirePerm(c, u, "ops.retry", "无 ops.retry 权限") {
		return
	}
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil || id <= 0 {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id")
		return
	}

	var req opsJobActionReq
	if err := c.ShouldBindJSON(&req); err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
		return
	}
	reason := strings.TrimSpace(req.Reason)
	if reason == "" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "retry 必须提供 reason")
		return
	}

	var old models.OpsJob
	if err := s.db.First(&old, id).Error; err != nil {
		respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found")
		return
	}
	if strings.TrimSpace(old.Status) != "failed" {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "only failed jobs can retry")
		return
	}

	newID, err := ops.RetryJobWithDB(s.db, filepath.Clean(s.baseDir), uint(id))
	if err != nil {
		respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
		return
	}
	s.writeAuditResult(u.UserID, "ops.job.retry", "ops_job", strconv.Itoa(id), "", "", reason, "success")
	respondOK(c, "retried", gin.H{"old_job_id": id, "new_job_id": newID, "reason": reason})
}
