package app

import (
	"archive/tar"
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"database/sql"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
)

const backupTelegramLimit = 49 << 20
const googleDriveUploadChunkSize = 8 << 20

type backupJob struct {
	Status    string    `json:"status"`
	StartedAt time.Time `json:"startedAt"`
	Error     string    `json:"error,omitempty"`
}

type backupItem struct {
	Name      string    `json:"name"`
	Size      int64     `json:"size"`
	CreatedAt time.Time `json:"createdAt"`
	SHA256    string    `json:"sha256,omitempty"`
}

type backupTransfer struct {
	Provider   string    `json:"provider"`
	Name       string    `json:"name"`
	Status     string    `json:"status"`
	Uploaded   int64     `json:"uploaded"`
	Total      int64     `json:"total"`
	StartedAt  time.Time `json:"startedAt"`
	FinishedAt time.Time `json:"finishedAt,omitempty"`
	Error      string    `json:"error,omitempty"`
}

type backupListResponse struct {
	Enabled       bool              `json:"enabled"`
	TelegramSet   bool              `json:"telegramSet"`
	TelegramLimit int64             `json:"telegramLimit"`
	Job           *backupJob        `json:"job,omitempty"`
	Items         []backupItem      `json:"items"`
	Schedule      backupSchedule    `json:"schedule"`
	GoogleDrive   googleDriveStatus `json:"googleDrive"`
	Transfers     []backupTransfer  `json:"transfers"`
}

type createBackupRequest struct {
	Password          string `json:"password"`
	ConfirmPassword   string `json:"confirmPassword"`
	SendTelegram      bool   `json:"sendTelegram"`
	UploadGoogleDrive bool   `json:"uploadGoogleDrive"`
}

type backupSchedule struct {
	Enabled            bool   `json:"enabled"`
	Days               int    `json:"days"`
	PasswordSet        bool   `json:"passwordSet"`
	PasswordHint       string `json:"passwordHint,omitempty"`
	ServerIP           string `json:"serverIp"`
	ChatID             string `json:"chatId"`
	TelegramMode       string `json:"telegramMode"`
	TelegramEnabled    bool   `json:"telegramEnabled"`
	GoogleDriveEnabled bool   `json:"googleDriveEnabled"`
}

func detectPublicServerIP(ctx context.Context, hostname string) string {
	hostname = strings.TrimSpace(hostname)
	if hostname == "" {
		return ""
	}
	if ip := net.ParseIP(hostname); ip != nil {
		if isPublicIP(ip) {
			return ip.String()
		}
		return ""
	}
	lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname)
	if err != nil {
		return ""
	}
	var ipv6 string
	for _, address := range addresses {
		if !isPublicIP(address.IP) {
			continue
		}
		if address.IP.To4() != nil {
			return address.IP.String()
		}
		if ipv6 == "" {
			ipv6 = address.IP.String()
		}
	}
	return ipv6
}

func isPublicIP(ip net.IP) bool {
	return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}

type updateBackupScheduleRequest struct {
	Enabled            bool   `json:"enabled"`
	Days               int    `json:"days"`
	Password           string `json:"password"`
	ConfirmPassword    string `json:"confirmPassword"`
	ServerIP           string `json:"serverIp"`
	ChatID             string `json:"chatId"`
	TelegramMode       string `json:"telegramMode"`
	TelegramEnabled    bool   `json:"telegramEnabled"`
	GoogleDriveEnabled bool   `json:"googleDriveEnabled"`
	GoogleClientID     string `json:"googleClientId"`
	GoogleClientSecret string `json:"googleClientSecret"`
	GoogleFolderName   string `json:"googleFolderName"`
}

type updateBackupPasswordRequest struct {
	Password        string `json:"password"`
	ConfirmPassword string `json:"confirmPassword"`
}

type testBackupTelegramRequest struct {
	Mode   string `json:"mode"`
	ChatID string `json:"chatId"`
}

type googleDriveStatus struct {
	ClientID        string `json:"clientId"`
	ClientSecretSet bool   `json:"clientSecretSet"`
	Connected       bool   `json:"connected"`
	FolderName      string `json:"folderName"`
}

type googleDriveOAuthState struct {
	ExpiresAt int64  `json:"expiresAt"`
	Nonce     string `json:"nonce"`
}

type googleDriveToken struct {
	AccessToken  string    `json:"accessToken"`
	RefreshToken string    `json:"refreshToken"`
	Expiry       time.Time `json:"expiry"`
}

func (a *App) requireSystemAdmin(w http.ResponseWriter, r *http.Request) bool {
	user := currentUser(r)
	if user == nil || user.Role != "admin" {
		respondError(w, http.StatusForbidden, "system administrator required")
		return false
	}
	return true
}

func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	items, err := a.listBackups()
	if err != nil {
		respondError(w, http.StatusInternalServerError, "无法读取备份列表")
		return
	}
	a.backupMu.Lock()
	job := a.backupJob
	if job != nil {
		copy := *job
		job = &copy
	}
	transfers := make([]backupTransfer, 0, len(a.backupTransfers))
	for _, transfer := range a.backupTransfers {
		copy := *transfer
		transfers = append(transfers, copy)
	}
	a.backupMu.Unlock()
	sort.Slice(transfers, func(i, j int) bool { return transfers[i].StartedAt.After(transfers[j].StartedAt) })
	schedule, _ := a.loadBackupSchedule(r.Context())
	schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
	telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
	respondJSON(w, http.StatusOK, backupListResponse{
		Enabled:       a.backupAssetsAvailable(),
		TelegramSet:   strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
		TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
		GoogleDrive: a.loadGoogleDriveStatus(r.Context()), Transfers: transfers,
	})
}

func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	var req createBackupRequest
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	a.backupMu.Lock()
	locked := true
	defer func() {
		if locked {
			a.backupMu.Unlock()
		}
	}()
	if a.backupJob != nil && a.backupJob.Status == "running" {
		respondError(w, http.StatusConflict, "已有备份任务正在运行")
		return
	}
	password, err := a.savedBackupPassword(r.Context())
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		respondError(w, http.StatusInternalServerError, "无法读取已保存的备份密码")
		return
	}
	if password == "" {
		if !validBackupPassword(req.Password) {
			badRequest(w, errors.New("首次创建备份时，密码至少需要 8 个字符"))
			return
		}
		if req.Password != req.ConfirmPassword {
			badRequest(w, errors.New("两次输入的备份密码不一致"))
			return
		}
	}
	if !a.backupAssetsAvailable() {
		respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份")
		return
	}
	if password == "" {
		ciphertext, encryptErr := a.encryptBackupPassword(req.Password)
		if encryptErr != nil {
			respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
			return
		}
		now := a.now().UTC().Format(time.RFC3339Nano)
		if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
			respondError(w, http.StatusInternalServerError, "无法保存备份密码")
			return
		}
		password = req.Password
	}
	a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
	a.backupMu.Unlock()
	locked = false
	sendTelegram, uploadGoogleDrive := req.SendTelegram, req.UploadGoogleDrive
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
		defer cancel()
		path, err := a.createDisasterBackup(ctx, password)
		password = ""
		var deliveryMessages []string
		if err == nil {
			var deliveryErrors []error
			if uploadGoogleDrive {
				a.queueBackupTransfer("googleDrive", path)
			}
			if sendTelegram {
				a.queueBackupTransfer("telegram", path)
			}
			if uploadGoogleDrive {
				if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
					message := googleDriveUploadMessage(driveErr)
					a.finishBackupTransfer("googleDrive", path, message)
					deliveryMessages = append(deliveryMessages, message)
					deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
				} else {
					a.finishBackupTransfer("googleDrive", path, "")
				}
			}
			if sendTelegram {
				if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
					a.finishBackupTransfer("telegram", path, telegramErr.Error())
					deliveryMessages = append(deliveryMessages, "Telegram 发送失败："+telegramErr.Error())
					deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
				} else {
					a.finishBackupTransfer("telegram", path, "")
				}
			}
			err = errors.Join(deliveryErrors...)
		}
		a.backupMu.Lock()
		if err != nil {
			a.backupJob.Status = "failed"
			if len(deliveryMessages) > 0 {
				a.backupJob.Error = strings.Join(deliveryMessages, "；")
			} else {
				a.backupJob.Error = "本地备份创建失败，请检查服务器存储空间"
			}
			a.log.Error("create disaster backup", "error", err)
		} else {
			a.backupJob.Status = "success"
		}
		a.backupMu.Unlock()
	}()
	respondJSON(w, http.StatusAccepted, map[string]any{"ok": true, "message": "备份任务已开始"})
}

func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	var req updateBackupScheduleRequest
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	if req.Days < 1 || req.Days > 365 {
		badRequest(w, errors.New("备份周期必须为 1 至 365 天"))
		return
	}
	var ciphertext string
	_ = a.db.QueryRowContext(r.Context(), `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext)
	if req.Password != "" {
		if !validBackupPassword(req.Password) {
			badRequest(w, errors.New("备份密码至少需要 8 个字符"))
			return
		}
		if req.Password != req.ConfirmPassword {
			badRequest(w, errors.New("两次输入的备份密码不一致"))
			return
		}
		var err error
		ciphertext, err = a.encryptBackupPassword(req.Password)
		if err != nil {
			respondError(w, 500, "无法安全保存备份密码")
			return
		}
	}
	if req.Enabled && ciphertext == "" {
		badRequest(w, errors.New("启用定时备份前请设置备份密码"))
		return
	}
	if req.Enabled && strings.TrimSpace(a.config().UpdateServiceToken) == "" {
		badRequest(w, errors.New("当前部署缺少备份密码加密密钥，请先更新部署配置"))
		return
	}
	chatID := strings.TrimSpace(req.ChatID)
	if chatID != "" && !validTelegramPrivateChatID(chatID) {
		badRequest(w, errors.New("备份 Telegram Chat ID 无效"))
		return
	}
	telegramMode := strings.TrimSpace(req.TelegramMode)
	if telegramMode != "custom" {
		telegramMode = "system"
	}
	if telegramMode == "custom" && !validTelegramPrivateChatID(chatID) {
		badRequest(w, errors.New("请选择备份群组并填写有效的 Chat ID"))
		return
	}
	if req.Enabled && req.TelegramEnabled {
		cfg := a.config()
		destination := cfg.TelegramPrivateChatID
		if telegramMode == "custom" {
			destination = chatID
		}
		if cfg.TelegramBotToken == "" || !validTelegramPrivateChatID(destination) {
			badRequest(w, errors.New("启用定时推送前请先在系统设置绑定 Telegram 机器人并配置接收位置"))
			return
		}
	}
	if req.Enabled && req.GoogleDriveEnabled && !a.loadGoogleDriveStatus(r.Context()).Connected {
		badRequest(w, errors.New("启用 Google 云端硬盘备份前请先完成授权"))
		return
	}
	secretCipher := ""
	_ = a.db.QueryRowContext(r.Context(), `SELECT value FROM system_settings WHERE key='backupGoogleClientSecretCipher'`).Scan(&secretCipher)
	if strings.TrimSpace(req.GoogleClientSecret) != "" {
		var err error
		secretCipher, err = a.encryptBackupPassword(strings.TrimSpace(req.GoogleClientSecret))
		if err != nil {
			respondError(w, 500, "无法安全保存 Google 客户端密钥")
			return
		}
	}
	folderName := strings.TrimSpace(req.GoogleFolderName)
	if folderName == "" {
		folderName = "NewSzxcn Backups"
	}
	values := map[string]string{
		"backupScheduleEnabled": fmt.Sprint(req.Enabled), "backupScheduleDays": fmt.Sprint(req.Days),
		"backupServerIp": "", "backupTelegramChatId": chatID,
		"backupTelegramMode":   telegramMode,
		"backupPasswordCipher": ciphertext, "backupTelegramEnabled": fmt.Sprint(req.TelegramEnabled),
		"backupGoogleDriveEnabled": fmt.Sprint(req.GoogleDriveEnabled), "backupGoogleClientId": strings.TrimSpace(req.GoogleClientID),
		"backupGoogleClientSecretCipher": secretCipher, "backupGoogleFolderName": folderName,
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	tx, err := a.db.BeginTx(r.Context(), nil)
	if err != nil {
		respondError(w, 500, "保存失败")
		return
	}
	defer tx.Rollback()
	for key, value := range values {
		if _, err = tx.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, key, value, now); err != nil {
			respondError(w, 500, "保存失败")
			return
		}
	}
	if err = tx.Commit(); err != nil {
		respondError(w, 500, "保存失败")
		return
	}
	passwordHint := ""
	if password, err := a.decryptBackupPassword(ciphertext); err == nil {
		passwordHint = backupPasswordHint(password)
	}
	respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", PasswordHint: passwordHint, ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled})
}

func (a *App) handleUpdateBackupPassword(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	var req updateBackupPasswordRequest
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	if !validBackupPassword(req.Password) {
		badRequest(w, errors.New("备份密码至少需要 8 个字符"))
		return
	}
	if req.Password != req.ConfirmPassword {
		badRequest(w, errors.New("两次输入的备份密码不一致"))
		return
	}
	ciphertext, err := a.encryptBackupPassword(req.Password)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "无法安全保存备份密码")
		return
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
		respondError(w, http.StatusInternalServerError, "无法保存备份密码")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"passwordSet": true, "passwordHint": backupPasswordHint(req.Password)})
}

func validBackupPassword(password string) bool {
	return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00")
}

func backupPasswordHint(password string) string {
	runes := []rune(password)
	if len(runes) < 2 {
		return ""
	}
	return string(runes[0]) + strings.Repeat("•", minimumInt(len(runes)-2, 10)) + string(runes[len(runes)-1])
}

func (a *App) savedBackupPassword(ctx context.Context) (string, error) {
	var ciphertext string
	if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil {
		return "", err
	}
	if strings.TrimSpace(ciphertext) == "" {
		return "", sql.ErrNoRows
	}
	return a.decryptBackupPassword(ciphertext)
}

func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	var req testBackupTelegramRequest
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	cfg := a.config()
	token, chatID := strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(cfg.TelegramPrivateChatID)
	if req.Mode == "custom" {
		chatID = strings.TrimSpace(req.ChatID)
	}
	if token == "" || !validTelegramPrivateChatID(chatID) {
		badRequest(w, errors.New("请先完成 Telegram 机器人和 Chat ID 配置"))
		return
	}
	now := a.now().Local().Format("2006-01-02 15:04:05 MST")
	message := "<b>NewSzxcn 备份通知测试</b>\n\nTelegram 备份接收配置正常。\n\n<b>测试时间：</b>" + htmlEscape(now)
	if err := a.sendTelegramMessage(r.Context(), token, chatID, message); err != nil {
		respondError(w, http.StatusBadGateway, err.Error())
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) handleDiscoverBackupTelegramGroup(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	var req telegramCredentialsRequest
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	token := strings.TrimSpace(a.config().TelegramBotToken)
	code := strings.ToUpper(strings.TrimSpace(req.PairingCode))
	if token == "" || code == "" {
		badRequest(w, errors.New("请先生成 Telegram 群组查询码"))
		return
	}
	a.telegramPairMu.Lock()
	pairing, ok := a.telegramPairs[code]
	a.telegramPairMu.Unlock()
	if !ok || !pairing.ExpiresAt.After(a.now().UTC()) || pairing.TokenFingerprint != telegramTokenFingerprint(token) {
		badRequest(w, errors.New("Telegram 群组查询码无效或已过期，请重新生成"))
		return
	}
	groups, err := a.discoverTelegramGroups(r.Context(), token, code)
	if err != nil {
		respondError(w, http.StatusBadGateway, err.Error())
		return
	}
	a.telegramPairMu.Lock()
	delete(a.telegramPairs, code)
	a.telegramPairMu.Unlock()
	respondJSON(w, http.StatusOK, map[string]any{"items": groups})
}

func (a *App) googleDriveOAuthConfig(ctx context.Context) (*oauth2.Config, error) {
	values := map[string]string{}
	rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher')`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var key, value string
		if err := rows.Scan(&key, &value); err != nil {
			return nil, err
		}
		values[key] = value
	}
	secret := ""
	if values["backupGoogleClientSecretCipher"] != "" {
		secret, err = a.decryptBackupPassword(values["backupGoogleClientSecretCipher"])
		if err != nil {
			return nil, err
		}
	}
	if strings.TrimSpace(values["backupGoogleClientId"]) == "" || secret == "" {
		return nil, errors.New("请先填写并保存 Google OAuth 客户端 ID 和密钥")
	}
	return &oauth2.Config{ClientID: values["backupGoogleClientId"], ClientSecret: secret, RedirectURL: strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/admin/backups/google-drive/callback", Scopes: []string{"https://www.googleapis.com/auth/drive.file"}, Endpoint: oauth2.Endpoint{AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token"}}, nil
}

func (a *App) handleGoogleDriveConnect(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	conf, err := a.googleDriveOAuthConfig(r.Context())
	if err != nil {
		badRequest(w, err)
		return
	}
	raw, _ := json.Marshal(googleDriveOAuthState{ExpiresAt: a.now().Add(10 * time.Minute).Unix(), Nonce: newID("drive")})
	state, err := a.encryptBackupPassword(string(raw))
	if err != nil {
		respondError(w, 500, "无法创建授权请求")
		return
	}
	state = base64.RawURLEncoding.EncodeToString([]byte(state))
	respondJSON(w, 200, map[string]string{"url": conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)})
}

func (a *App) handleGoogleDriveCallback(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	encoded, err := base64.RawURLEncoding.DecodeString(r.URL.Query().Get("state"))
	if err != nil {
		badRequest(w, errors.New("Google 授权状态无效"))
		return
	}
	plain, err := a.decryptBackupPassword(string(encoded))
	if err != nil {
		badRequest(w, errors.New("Google 授权状态无效"))
		return
	}
	var state googleDriveOAuthState
	if json.Unmarshal([]byte(plain), &state) != nil || state.ExpiresAt < a.now().Unix() {
		badRequest(w, errors.New("Google 授权已过期，请重新连接"))
		return
	}
	if oauthErr := r.URL.Query().Get("error"); oauthErr != "" {
		http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/admin?section=backups&drive=error", http.StatusFound)
		return
	}
	conf, err := a.googleDriveOAuthConfig(r.Context())
	if err != nil {
		badRequest(w, err)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()
	token, err := conf.Exchange(ctx, r.URL.Query().Get("code"))
	if err != nil {
		respondError(w, 400, "Google 授权交换失败")
		return
	}
	raw, _ := json.Marshal(googleDriveToken{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry})
	ciphertext, err := a.encryptBackupPassword(string(raw))
	if err != nil {
		respondError(w, 500, "无法保存 Google 授权")
		return
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	_, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now)
	if err != nil {
		respondError(w, 500, "无法保存 Google 授权")
		return
	}
	http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/admin?section=backups&drive=connected", http.StatusFound)
}

func (a *App) handleGoogleDriveDisconnect(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	_, err := a.db.ExecContext(r.Context(), `DELETE FROM system_settings WHERE key IN ('backupGoogleTokenCipher','backupGoogleDriveEnabled')`)
	if err != nil {
		respondError(w, 500, "断开失败")
		return
	}
	respondJSON(w, 200, map[string]bool{"ok": true})
}

func (a *App) loadGoogleDriveStatus(ctx context.Context) googleDriveStatus {
	status := googleDriveStatus{FolderName: "NewSzxcn Backups"}
	rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher','backupGoogleTokenCipher','backupGoogleFolderName')`)
	if err != nil {
		return status
	}
	defer rows.Close()
	for rows.Next() {
		var key, value string
		if rows.Scan(&key, &value) != nil {
			continue
		}
		switch key {
		case "backupGoogleClientId":
			status.ClientID = value
		case "backupGoogleClientSecretCipher":
			status.ClientSecretSet = value != ""
		case "backupGoogleTokenCipher":
			status.Connected = value != ""
		case "backupGoogleFolderName":
			if strings.TrimSpace(value) != "" {
				status.FolderName = value
			}
		}
	}
	return status
}

func (a *App) createDisasterBackup(ctx context.Context, password string) (string, error) {
	cfg := a.config()
	if !a.backupAssetsAvailable() {
		return "", errors.New("backup directories are not configured")
	}
	if err := os.MkdirAll(cfg.BackupDir, 0o700); err != nil {
		return "", err
	}
	work, err := os.MkdirTemp(cfg.BackupDir, ".staging-")
	if err != nil {
		return "", err
	}
	defer os.RemoveAll(work)
	root := filepath.Join(work, "newszxcn-backup")
	for _, dir := range []string{"data", "mail", "dkim", "certs"} {
		if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil {
			return "", err
		}
	}
	quoted := strings.ReplaceAll(filepath.Join(root, "data", "lanqin.db"), "'", "''")
	if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quoted+"'"); err != nil {
		return "", err
	}
	if err := copyTree(cfg.DataDir, filepath.Join(root, "data"), map[string]bool{"lanqin.db": true, "lanqin.db-wal": true, "lanqin.db-shm": true, "backups": true, "disaster-backups": true}); err != nil {
		return "", err
	}
	if cfg.MaildirRoot != "" {
		if err := copyTree(cfg.MaildirRoot, filepath.Join(root, "mail"), nil); err != nil {
			return "", err
		}
	}
	for _, item := range []struct{ src, dst string }{{"/var/lib/rspamd/dkim", "dkim"}, {"/certs", "certs"}} {
		if err := copyTree(item.src, filepath.Join(root, item.dst), nil); err != nil && !os.IsNotExist(err) {
			return "", err
		}
	}
	if err := copyFile(filepath.Join(cfg.BackupSourceDir, "docker-compose.yml"), filepath.Join(root, "docker-compose.yml")); err != nil {
		return "", err
	}
	if err := copyFile(filepath.Join(cfg.BackupSourceDir, ".env"), filepath.Join(root, ".env")); err != nil {
		if !os.IsNotExist(err) {
			return "", err
		}
		if err := writeRuntimeBackupEnv(filepath.Join(root, ".env")); err != nil {
			return "", err
		}
	}
	manifest := map[string]any{"format": 1, "version": cfg.AppVersion, "createdAt": a.now().UTC(), "hostname": cfg.PublicHostname}
	raw, _ := json.MarshalIndent(manifest, "", "  ")
	if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o600); err != nil {
		return "", err
	}
	tarPath := filepath.Join(work, "backup.tar")
	if err := writeTar(tarPath, root); err != nil {
		return "", err
	}
	zstPath := tarPath + ".zst"
	if output, err := exec.CommandContext(ctx, "zstd", "-q", "-T0", "-10", tarPath, "-o", zstPath).CombinedOutput(); err != nil {
		return "", fmt.Errorf("zstd: %w: %s", err, output)
	}
	name := fmt.Sprintf("newszxcn-backup-%s-%s.tar.zst.enc", a.now().UTC().Format("20060102-150405"), strings.TrimPrefix(cfg.AppVersion, "v"))
	outPath := filepath.Join(cfg.BackupDir, name)
	cmd := exec.CommandContext(ctx, "openssl", "enc", "-aes-256-cbc", "-salt", "-pbkdf2", "-iter", "200000", "-md", "sha256", "-in", zstPath, "-out", outPath, "-pass", "stdin")
	cmd.Stdin = strings.NewReader(password)
	if output, err := cmd.CombinedOutput(); err != nil {
		os.Remove(outPath)
		return "", fmt.Errorf("openssl: %w: %s", err, output)
	}
	if err := os.Chmod(outPath, 0o600); err != nil {
		_ = os.Remove(outPath)
		return "", err
	}
	sum, err := fileSHA256(outPath)
	if err != nil {
		_ = os.Remove(outPath)
		return "", err
	}
	if err := os.WriteFile(outPath+".sha256", []byte(sum+"  "+name+"\n"), 0o600); err != nil {
		_ = os.Remove(outPath)
		return "", err
	}
	if err := a.pruneDisasterBackups(10); err != nil {
		a.log.Warn("prune disaster backups", "error", err)
	}
	return outPath, nil
}

func (a *App) backupAssetsAvailable() bool {
	cfg := a.config()
	if strings.TrimSpace(cfg.BackupDir) == "" || strings.TrimSpace(cfg.BackupSourceDir) == "" {
		return false
	}
	info, err := os.Stat(filepath.Join(cfg.BackupSourceDir, "docker-compose.yml"))
	return err == nil && info.Mode().IsRegular()
}

func writeRuntimeBackupEnv(path string) error {
	values := make([]string, 0)
	containerOnly := map[string]bool{
		"LANQIN_BACKUP_DIR":           true,
		"LANQIN_BACKUP_SOURCE_DIR":    true,
		"LANQIN_UPDATE_SERVICE_TOKEN": true,
		"LANQIN_UPDATE_SERVICE_URL":   true,
	}
	for _, item := range os.Environ() {
		key, value, found := strings.Cut(item, "=")
		if !found || containerOnly[key] || (!strings.HasPrefix(key, "LANQIN_") && key != "TZ") {
			continue
		}
		value = strings.ReplaceAll(value, "\\", "\\\\")
		value = strings.ReplaceAll(value, "'", "\\'")
		value = strings.ReplaceAll(value, "\r", "\\r")
		value = strings.ReplaceAll(value, "\n", "\\n")
		values = append(values, key+"='"+value+"'")
	}
	sort.Strings(values)
	return os.WriteFile(path, []byte(strings.Join(values, "\n")+"\n"), 0o600)
}

func (a *App) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	path, ok := a.backupPath(chi.URLParam(r, "name"))
	if !ok {
		respondError(w, http.StatusNotFound, "备份不存在")
		return
	}
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
	w.Header().Set("Content-Type", "application/octet-stream")
	http.ServeFile(w, r, path)
}

func (a *App) handleVerifyBackup(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	path, ok := a.backupPath(chi.URLParam(r, "name"))
	if !ok {
		respondError(w, http.StatusNotFound, "备份不存在")
		return
	}
	actual, err := fileSHA256(path)
	if err != nil {
		respondError(w, 500, "校验失败")
		return
	}
	expectedRaw, err := os.ReadFile(path + ".sha256")
	if err != nil {
		respondError(w, 500, "校验文件缺失")
		return
	}
	expected := strings.Fields(string(expectedRaw))
	valid := len(expected) > 0 && expected[0] == actual
	respondJSON(w, 200, map[string]any{"ok": valid, "sha256": actual})
}

func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	path, ok := a.backupPath(chi.URLParam(r, "name"))
	if !ok {
		respondError(w, 404, "备份不存在")
		return
	}
	if err := os.Remove(path); err != nil {
		respondError(w, 500, "删除失败")
		return
	}
	_ = os.Remove(path + ".sha256")
	a.backupMu.Lock()
	delete(a.backupTransfers, backupTransferKey("telegram", path))
	delete(a.backupTransfers, backupTransferKey("googleDrive", path))
	a.backupMu.Unlock()
	respondJSON(w, 200, map[string]any{"ok": true})
}

func (a *App) handleSendBackupTelegram(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	path, ok := a.backupPath(chi.URLParam(r, "name"))
	if !ok {
		respondError(w, 404, "备份不存在")
		return
	}
	if !a.startBackupTransfer("telegram", path) {
		respondError(w, http.StatusConflict, "该备份正在发送到 Telegram")
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
		defer cancel()
		if err := a.sendBackupToTelegram(ctx, path); err != nil {
			a.finishBackupTransfer("telegram", path, err.Error())
			a.log.Error("send backup telegram", "error", err)
			return
		}
		a.finishBackupTransfer("telegram", path, "")
	}()
	respondJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
	if !a.requireSystemAdmin(w, r) {
		return
	}
	path, ok := a.backupPath(chi.URLParam(r, "name"))
	if !ok {
		respondError(w, 404, "备份不存在")
		return
	}
	if !a.startBackupTransfer("googleDrive", path) {
		respondError(w, http.StatusConflict, "该备份正在上传到 Google 云端硬盘")
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
		defer cancel()
		if err := a.uploadBackupToGoogleDrive(ctx, path); err != nil {
			message := googleDriveUploadMessage(err)
			a.finishBackupTransfer("googleDrive", path, message)
			a.log.Error("upload backup to google drive", "error", err)
			return
		}
		a.finishBackupTransfer("googleDrive", path, "")
	}()
	respondJSON(w, http.StatusAccepted, map[string]bool{"ok": true})
}

func backupTransferKey(provider, path string) string {
	return provider + ":" + filepath.Base(path)
}

func (a *App) startBackupTransfer(provider, path string) bool {
	info, err := os.Stat(path)
	if err != nil {
		return false
	}
	key := backupTransferKey(provider, path)
	a.backupMu.Lock()
	defer a.backupMu.Unlock()
	if transfer := a.backupTransfers[key]; transfer != nil && transfer.Status == "running" {
		return false
	}
	a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
	return true
}

func (a *App) ensureBackupTransfer(provider, path string) {
	info, err := os.Stat(path)
	if err != nil {
		return
	}
	key := backupTransferKey(provider, path)
	a.backupMu.Lock()
	defer a.backupMu.Unlock()
	if transfer := a.backupTransfers[key]; transfer == nil {
		a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
	} else if transfer.Status == "queued" {
		transfer.Status = "running"
	}
}

func (a *App) queueBackupTransfer(provider, path string) {
	info, err := os.Stat(path)
	if err != nil {
		return
	}
	a.backupMu.Lock()
	defer a.backupMu.Unlock()
	a.backupTransfers[backupTransferKey(provider, path)] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "queued", Total: info.Size(), StartedAt: a.now().UTC()}
}

func (a *App) updateBackupTransfer(provider, path string, uploaded int64) {
	a.backupMu.Lock()
	defer a.backupMu.Unlock()
	if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
		transfer.Uploaded = uploaded
	}
}

func (a *App) finishBackupTransfer(provider, path, message string) {
	a.backupMu.Lock()
	defer a.backupMu.Unlock()
	if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
		transfer.FinishedAt = a.now().UTC()
		if message == "" {
			transfer.Status = "success"
			transfer.Uploaded = transfer.Total
		} else {
			transfer.Status = "failed"
			transfer.Error = message
		}
	}
}

func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
	conf, err := a.googleDriveOAuthConfig(ctx)
	if err != nil {
		return nil, err
	}
	var ciphertext string
	if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupGoogleTokenCipher'`).Scan(&ciphertext); err != nil {
		return nil, errors.New("Google 云端硬盘尚未连接")
	}
	plain, err := a.decryptBackupPassword(ciphertext)
	if err != nil {
		return nil, err
	}
	var saved googleDriveToken
	if err := json.Unmarshal([]byte(plain), &saved); err != nil {
		return nil, err
	}
	original := &oauth2.Token{AccessToken: saved.AccessToken, RefreshToken: saved.RefreshToken, Expiry: saved.Expiry, TokenType: "Bearer"}
	refreshed, err := conf.TokenSource(ctx, original).Token()
	if err != nil {
		return nil, err
	}
	if refreshed.AccessToken != original.AccessToken || !refreshed.Expiry.Equal(original.Expiry) {
		refreshToken := refreshed.RefreshToken
		if refreshToken == "" {
			refreshToken = saved.RefreshToken
		}
		raw, _ := json.Marshal(googleDriveToken{AccessToken: refreshed.AccessToken, RefreshToken: refreshToken, Expiry: refreshed.Expiry})
		ciphertext, encryptErr := a.encryptBackupPassword(string(raw))
		if encryptErr != nil {
			return nil, encryptErr
		}
		now := a.now().UTC().Format(time.RFC3339Nano)
		if _, err := a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil {
			return nil, err
		}
	}
	return oauth2.NewClient(ctx, oauth2.StaticTokenSource(refreshed)), nil
}

func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name string) (string, error) {
	escaped := strings.ReplaceAll(name, "'", "\\'")
	query := fmt.Sprintf("name = '%s' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", escaped)
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/drive/v3/files?spaces=drive&fields=files(id,name)&pageSize=1&q="+url.QueryEscape(query), nil)
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return "", &googleDriveAPIError{Operation: "folder lookup", StatusCode: resp.StatusCode, Body: string(raw)}
	}
	var list struct {
		Files []struct {
			ID string `json:"id"`
		} `json:"files"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
		return "", err
	}
	if len(list.Files) > 0 {
		return list.Files[0].ID, nil
	}
	body, _ := json.Marshal(map[string]any{"name": name, "mimeType": "application/vnd.google-apps.folder"})
	req, _ = http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/drive/v3/files?fields=id", strings.NewReader(string(body)))
	req.Header.Set("Content-Type", "application/json")
	resp, err = client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return "", &googleDriveAPIError{Operation: "folder create", StatusCode: resp.StatusCode, Body: string(raw)}
	}
	var created struct {
		ID string `json:"id"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
		return "", err
	}
	if created.ID == "" {
		return "", errors.New("Google 云端硬盘未返回文件夹 ID")
	}
	return created.ID, nil
}

func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
	a.ensureBackupTransfer("googleDrive", path)
	client, err := a.googleDriveClient(ctx)
	if err != nil {
		return err
	}
	folder := a.loadGoogleDriveStatus(ctx).FolderName
	folderID, err := a.googleDriveFolderID(ctx, client, folder)
	if err != nil {
		return err
	}
	req, size, err := newGoogleDriveResumableRequest(ctx, path, folderID)
	if err != nil {
		return err
	}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return &googleDriveAPIError{Operation: "start upload", StatusCode: resp.StatusCode, Body: string(raw)}
	}
	location := strings.TrimSpace(resp.Header.Get("Location"))
	if location == "" {
		return errors.New("Google 云端硬盘未返回可恢复上传地址")
	}
	return a.uploadGoogleDriveChunks(ctx, client, location, path, size)
}

type googleDriveAPIError struct {
	Operation  string
	StatusCode int
	Body       string
}

func (e *googleDriveAPIError) Error() string {
	return fmt.Sprintf("google drive %s returned %d: %s", e.Operation, e.StatusCode, e.Body)
}

func newGoogleDriveResumableRequest(ctx context.Context, path, folderID string) (*http.Request, int64, error) {
	info, err := os.Stat(path)
	if err != nil {
		return nil, 0, err
	}
	metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name", strings.NewReader(string(metadata)))
	if err != nil {
		return nil, 0, err
	}
	req.Header.Set("Content-Type", "application/json; charset=UTF-8")
	req.Header.Set("X-Upload-Content-Type", "application/octet-stream")
	req.Header.Set("X-Upload-Content-Length", fmt.Sprint(info.Size()))
	return req, info.Size(), nil
}

func (a *App) uploadGoogleDriveChunks(ctx context.Context, client *http.Client, location, path string, size int64) error {
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	for offset := int64(0); offset < size; {
		length := int64(googleDriveUploadChunkSize)
		if remaining := size - offset; remaining < length {
			length = remaining
		}
		end := offset + length - 1
		req, err := http.NewRequestWithContext(ctx, http.MethodPut, location, io.NewSectionReader(file, offset, length))
		if err != nil {
			return err
		}
		req.ContentLength = length
		req.Header.Set("Content-Type", "application/octet-stream")
		req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, size))
		resp, err := client.Do(req)
		if err != nil {
			return err
		}
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		_ = resp.Body.Close()
		if resp.StatusCode == http.StatusPermanentRedirect {
			offset += length
			a.updateBackupTransfer("googleDrive", path, offset)
			continue
		}
		if resp.StatusCode >= 200 && resp.StatusCode < 300 && end+1 == size {
			a.updateBackupTransfer("googleDrive", path, size)
			return nil
		}
		return &googleDriveAPIError{Operation: "upload chunk", StatusCode: resp.StatusCode, Body: string(raw)}
	}
	return errors.New("Google 云端硬盘不能上传空备份文件")
}

func googleDriveUploadMessage(err error) string {
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return "Google 云端硬盘上传超时，请检查服务器网络后重试"
	}
	var apiErr *googleDriveAPIError
	if errors.As(err, &apiErr) {
		body := strings.ToLower(apiErr.Body)
		switch {
		case apiErr.StatusCode == http.StatusUnauthorized || strings.Contains(body, "invalid_grant"):
			return "Google 授权已失效，请打开配置，断开后重新连接"
		case strings.Contains(body, "storagequota") || strings.Contains(body, "storage quota"):
			return "Google 云端硬盘空间不足，请清理空间后重试"
		case apiErr.StatusCode == http.StatusTooManyRequests || strings.Contains(body, "ratelimit"):
			return "Google 云端硬盘请求过于频繁，请稍后重试"
		case apiErr.StatusCode == http.StatusForbidden:
			return "Google 云端硬盘无上传权限，请确认 Drive API 已启用并重新连接"
		}
	}
	message := strings.ToLower(err.Error())
	if strings.Contains(message, "oauth2") || strings.Contains(message, "token") {
		return "Google 授权已失效，请打开配置，断开后重新连接"
	}
	return "上传 Google 云端硬盘失败，请检查服务器网络或重新连接 Google 账号"
}

func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
	a.ensureBackupTransfer("telegram", path)
	schedule, _ := a.loadBackupSchedule(ctx)
	token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
	if err != nil {
		return err
	}
	if token == "" || !validTelegramPrivateChatID(chatID) {
		return errors.New("请先在系统设置绑定 Telegram 机器人")
	}
	info, err := os.Stat(path)
	if err != nil {
		return err
	}
	if info.Size() > backupTelegramLimit {
		return errors.New("备份超过 Telegram 发送上限，请下载后保存到其他存储")
	}
	report, err := a.backupTelegramReport(ctx, path, info)
	if err != nil {
		return err
	}
	if err := a.sendTelegramMessage(ctx, token, chatID, report); err != nil {
		return err
	}
	return a.sendTelegramDocument(ctx, token, chatID, path)
}

func (a *App) backupTelegramCredentials(ctx context.Context, schedule backupSchedule) (string, string, error) {
	cfg := a.config()
	if schedule.TelegramMode != "custom" {
		return strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(cfg.TelegramPrivateChatID), nil
	}
	return strings.TrimSpace(cfg.TelegramBotToken), strings.TrimSpace(schedule.ChatID), nil
}

func (a *App) backupTelegramReport(ctx context.Context, path string, info os.FileInfo) (string, error) {
	cfg := a.config()
	sum, _ := fileSHA256(path)
	domains, err := queryBackupStrings(ctx, a.db, `SELECT name FROM domains ORDER BY name`)
	if err != nil {
		return "", err
	}
	admins, err := queryBackupStrings(ctx, a.db, `SELECT email FROM users WHERE role='admin' ORDER BY email`)
	if err != nil {
		return "", err
	}
	users, err := queryBackupStrings(ctx, a.db, `SELECT email FROM users WHERE role='user' ORDER BY email`)
	if err != nil {
		return "", err
	}
	mailboxes, err := queryBackupStrings(ctx, a.db, `SELECT address FROM mailboxes ORDER BY address`)
	if err != nil {
		return "", err
	}
	list := func(items []string) string {
		if len(items) == 0 {
			return "无"
		}
		total, suffix := len(items), ""
		if total > 10 {
			items = items[:10]
			suffix = fmt.Sprintf(" 等 %d 个", total)
		}
		for i := range items {
			value, truncated := truncateRunes(items[i], 80)
			if truncated {
				value += "..."
			}
			items[i] = htmlEscape(value)
		}
		return strings.Join(items, "、") + suffix
	}
	serverIP := detectPublicServerIP(ctx, cfg.PublicHostname)
	if serverIP == "" {
		serverIP = "未检测到"
	}
	return fmt.Sprintf("<b>%s 备份成功</b>\n\n<b>邮局域名：</b>%s\n<b>服务器 IP：</b>%s\n<b>系统版本：</b>%s\n\n<b>已有域名：</b>\n%s\n\n<b>管理员账号：</b>\n%s\n\n<b>普通用户账号：</b>\n%s\n\n<b>邮箱账号：</b>\n%s\n\n<b>备份文件：</b>%s\n<b>文件大小：</b>%s\n<b>SHA-256：</b><code>%s</code>\n\n<b>恢复教程：</b>\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 <code>/root/</code> 目录。\n3. 运行官方安装脚本，显示管理菜单后输入 2，选择“备份恢复”。\n4. 选择“本地上传”，系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中；多份时显示 1、2、3 等序号。\n6. 输入对应序号，例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后，账号继续使用原登录密码。\n9. 以后需要管理系统时，可以直接输入 ns 打开管理菜单。\n\n<b>安全提示：</b>备份密码不会发送到 Telegram，请从 1Password 等独立位置取用。", info.ModTime().Local().Format("2006-01-02"), htmlEscape(cfg.PublicHostname), htmlEscape(serverIP), htmlEscape(cfg.AppVersion), list(domains), list(admins), list(users), list(mailboxes), htmlEscape(filepath.Base(path)), humanBackupBytes(info.Size()), sum), nil
}

func queryBackupStrings(ctx context.Context, db *sql.DB, query string) ([]string, error) {
	rows, err := db.QueryContext(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var values []string
	for rows.Next() {
		var value string
		if err := rows.Scan(&value); err != nil {
			return nil, err
		}
		values = append(values, value)
	}
	return values, rows.Err()
}

func humanBackupBytes(value int64) string {
	if value < 1024 {
		return fmt.Sprintf("%d B", value)
	}
	units := []string{"KB", "MB", "GB", "TB"}
	size := float64(value)
	unit := "B"
	for _, next := range units {
		size /= 1024
		unit = next
		if size < 1024 {
			break
		}
	}
	return fmt.Sprintf("%.1f %s", size, unit)
}

func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path string) error {
	reader, pipeWriter := io.Pipe()
	writer := multipart.NewWriter(pipeWriter)
	go func() {
		var writeErr error
		defer func() { _ = writer.Close(); _ = pipeWriter.CloseWithError(writeErr) }()
		if writeErr = writer.WriteField("chat_id", chatID); writeErr != nil {
			return
		}
		if writeErr = writer.WriteField("caption", "NewSzxcn 加密备份\n请将备份密码单独保管，不要发送到同一聊天。"); writeErr != nil {
			return
		}
		var part io.Writer
		part, writeErr = writer.CreateFormFile("document", filepath.Base(path))
		if writeErr != nil {
			return
		}
		var file *os.File
		file, writeErr = os.Open(path)
		if writeErr != nil {
			return
		}
		defer file.Close()
		_, writeErr = io.Copy(part, &backupProgressReader{reader: file, onProgress: func(uploaded int64) { a.updateBackupTransfer("telegram", path, uploaded) }})
	}()
	endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())
	resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return fmt.Errorf("telegram %s: %s", resp.Status, raw)
	}
	return nil
}

type backupProgressReader struct {
	reader     io.Reader
	uploaded   int64
	onProgress func(int64)
}

func (r *backupProgressReader) Read(p []byte) (int, error) {
	n, err := r.reader.Read(p)
	if n > 0 {
		r.uploaded += int64(n)
		r.onProgress(r.uploaded)
	}
	return n, err
}

func (a *App) listBackups() ([]backupItem, error) {
	dir := a.config().BackupDir
	if dir == "" {
		return []backupItem{}, nil
	}
	entries, err := os.ReadDir(dir)
	if os.IsNotExist(err) {
		return []backupItem{}, nil
	}
	if err != nil {
		return nil, err
	}
	items := make([]backupItem, 0)
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar.zst.enc") {
			continue
		}
		info, err := entry.Info()
		if err != nil {
			continue
		}
		item := backupItem{Name: entry.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC()}
		if raw, err := os.ReadFile(filepath.Join(dir, entry.Name()+".sha256")); err == nil {
			fields := strings.Fields(string(raw))
			if len(fields) > 0 {
				item.SHA256 = fields[0]
			}
		}
		items = append(items, item)
	}
	sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
	return items, nil
}

func (a *App) pruneDisasterBackups(keep int) error {
	items, err := a.listBackups()
	if err != nil {
		return err
	}
	for _, item := range items[minimumInt(keep, len(items)):] {
		path, ok := a.backupPath(item.Name)
		if !ok {
			continue
		}
		if err := os.Remove(path); err != nil {
			return err
		}
		_ = os.Remove(path + ".sha256")
	}
	return nil
}

func minimumInt(left, right int) int {
	if left < right {
		return left
	}
	return right
}

func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) {
	result := backupSchedule{Days: 7, TelegramMode: "system", TelegramEnabled: true}
	rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupScheduleEnabled','backupScheduleDays','backupServerIp','backupTelegramChatId','backupTelegramMode','backupPasswordCipher','backupTelegramEnabled','backupGoogleDriveEnabled')`)
	if err != nil {
		return result, err
	}
	defer rows.Close()
	for rows.Next() {
		var key, value string
		if err := rows.Scan(&key, &value); err != nil {
			return result, err
		}
		switch key {
		case "backupScheduleEnabled":
			result.Enabled = value == "true"
		case "backupScheduleDays":
			if _, err := fmt.Sscan(value, &result.Days); err != nil || result.Days < 1 {
				result.Days = 7
			}
		case "backupServerIp":
			result.ServerIP = value
		case "backupTelegramChatId":
			result.ChatID = value
		case "backupTelegramMode":
			if value == "custom" {
				result.TelegramMode = "custom"
			}
		case "backupPasswordCipher":
			result.PasswordSet = value != ""
			if value != "" {
				if password, err := a.decryptBackupPassword(value); err == nil {
					result.PasswordHint = backupPasswordHint(password)
				}
			}
		case "backupTelegramEnabled":
			result.TelegramEnabled = value == "true"
		case "backupGoogleDriveEnabled":
			result.GoogleDriveEnabled = value == "true"
		}
	}
	return result, rows.Err()
}

func (a *App) backupScheduleWorker(ctx context.Context) {
	ticker := time.NewTicker(time.Hour)
	defer ticker.Stop()
	a.runScheduledBackup(ctx)
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			a.runScheduledBackup(ctx)
		}
	}
}

func (a *App) runScheduledBackup(ctx context.Context) {
	schedule, err := a.loadBackupSchedule(ctx)
	if err != nil || !schedule.Enabled {
		return
	}
	var last string
	_ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupScheduleLastRun'`).Scan(&last)
	if parsed, err := time.Parse(time.RFC3339Nano, last); err == nil && a.now().UTC().Before(parsed.Add(time.Duration(schedule.Days)*24*time.Hour)) {
		return
	}
	var ciphertext string
	if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil {
		return
	}
	password, err := a.decryptBackupPassword(ciphertext)
	if err != nil {
		a.log.Error("decrypt scheduled backup password", "error", err)
		return
	}
	a.backupMu.Lock()
	if a.backupJob != nil && a.backupJob.Status == "running" {
		a.backupMu.Unlock()
		return
	}
	a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
	a.backupMu.Unlock()
	path, runErr := a.createDisasterBackup(ctx, password)
	password = ""
	localBackupSucceeded := runErr == nil
	var deliveryMessages []string
	if localBackupSucceeded {
		now := a.now().UTC().Format(time.RFC3339Nano)
		_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
		var deliveryErrors []error
		if schedule.GoogleDriveEnabled {
			a.queueBackupTransfer("googleDrive", path)
		}
		if schedule.TelegramEnabled {
			a.queueBackupTransfer("telegram", path)
		}
		if schedule.GoogleDriveEnabled {
			if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
				message := googleDriveUploadMessage(driveErr)
				a.finishBackupTransfer("googleDrive", path, message)
				deliveryMessages = append(deliveryMessages, message)
				deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
			} else {
				a.finishBackupTransfer("googleDrive", path, "")
			}
		}
		if schedule.TelegramEnabled {
			if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
				a.finishBackupTransfer("telegram", path, telegramErr.Error())
				deliveryMessages = append(deliveryMessages, "Telegram 发送失败："+telegramErr.Error())
				deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
			} else {
				a.finishBackupTransfer("telegram", path, "")
			}
		}
		runErr = errors.Join(deliveryErrors...)
	}
	status := "success"
	publicError := ""
	if runErr != nil {
		status = "failed"
		if len(deliveryMessages) > 0 {
			publicError = strings.Join(deliveryMessages, "；")
		} else {
			publicError = "定时备份创建失败，请检查服务器存储空间"
		}
		a.log.Error("scheduled backup", "error", runErr)
	}
	a.backupMu.Lock()
	a.backupJob.Status, a.backupJob.Error = status, publicError
	a.backupMu.Unlock()
}

func (a *App) backupEncryptionKey() []byte {
	cfg := a.config()
	secret := strings.TrimSpace(cfg.UpdateServiceToken)
	if secret == "" {
		secret = strings.TrimSpace(cfg.ExternalIMAPSecretKey)
	}
	sum := sha256.Sum256([]byte("newszxcn-backup-schedule:" + secret))
	return sum[:]
}

func (a *App) encryptBackupPassword(password string) (string, error) {
	if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" {
		return "", errors.New("backup encryption key is not configured")
	}
	block, err := aes.NewCipher(a.backupEncryptionKey())
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(append(nonce, gcm.Seal(nil, nonce, []byte(password), nil)...)), nil
}

func (a *App) decryptBackupPassword(value string) (string, error) {
	if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" {
		return "", errors.New("backup encryption key is not configured")
	}
	raw, err := base64.StdEncoding.DecodeString(value)
	if err != nil {
		return "", err
	}
	block, err := aes.NewCipher(a.backupEncryptionKey())
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	if len(raw) < gcm.NonceSize() {
		return "", errors.New("invalid backup password ciphertext")
	}
	plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
	if err != nil {
		return "", err
	}
	return string(plain), nil
}

func (a *App) backupPath(name string) (string, bool) {
	if filepath.Base(name) != name || !strings.HasPrefix(name, "newszxcn-backup-") || !strings.HasSuffix(name, ".tar.zst.enc") {
		return "", false
	}
	path := filepath.Join(a.config().BackupDir, name)
	info, err := os.Stat(path)
	return path, err == nil && !info.IsDir()
}

func copyTree(src, dst string, skip map[string]bool) error {
	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		if rel == "." {
			return os.MkdirAll(dst, info.Mode().Perm())
		}
		first := strings.Split(rel, string(os.PathSeparator))[0]
		if skip[first] {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		target := filepath.Join(dst, rel)
		if info.IsDir() {
			return os.MkdirAll(target, info.Mode().Perm())
		}
		if !info.Mode().IsRegular() {
			return nil
		}
		return copyFile(path, target)
	})
}

func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	info, err := in.Stat()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
		return err
	}
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
	if err != nil {
		return err
	}
	_, cpErr := io.Copy(out, in)
	closeErr := out.Close()
	if cpErr != nil {
		return cpErr
	}
	return closeErr
}

func writeTar(path, root string) error {
	out, err := os.Create(path)
	if err != nil {
		return err
	}
	tw := tar.NewWriter(out)
	err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, _ := filepath.Rel(filepath.Dir(root), path)
		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}
		header.Name = filepath.ToSlash(rel)
		if err := tw.WriteHeader(header); err != nil {
			return err
		}
		if info.Mode().IsRegular() {
			f, err := os.Open(path)
			if err != nil {
				return err
			}
			_, err = io.Copy(tw, f)
			f.Close()
			return err
		}
		return nil
	})
	closeErr := tw.Close()
	fileCloseErr := out.Close()
	if err != nil {
		return err
	}
	if closeErr != nil {
		return closeErr
	}
	return fileCloseErr
}
func fileSHA256(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	hash := sha256.New()
	if _, err := io.Copy(hash, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(hash.Sum(nil)), nil
}
