package app

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

var (
	BuildVersion = "dev"
	BuildCommit  = ""
	BuildDate    = ""
)

type systemVersionInfo struct {
	CurrentVersion  string     `json:"currentVersion"`
	CurrentCommit   string     `json:"currentCommit,omitempty"`
	BuildDate       string     `json:"buildDate,omitempty"`
	LatestVersion   string     `json:"latestVersion,omitempty"`
	LatestName      string     `json:"latestName,omitempty"`
	ReleaseURL      string     `json:"releaseUrl,omitempty"`
	ReleaseNotes    string     `json:"releaseNotes,omitempty"`
	PublishedAt     *time.Time `json:"publishedAt,omitempty"`
	UpdateAvailable bool       `json:"updateAvailable"`
	UpdateEnabled   bool       `json:"updateEnabled"`
	CheckError      string     `json:"checkError,omitempty"`
}

type githubRelease struct {
	TagName     string    `json:"tag_name"`
	Name        string    `json:"name"`
	HTMLURL     string    `json:"html_url"`
	Body        string    `json:"body"`
	PublishedAt time.Time `json:"published_at"`
}

func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
	info, err := a.systemVersion(r.Context())
	if err != nil {
		info.CheckError = "暂时无法连接版本服务"
		a.log.Warn("check system version", "error", err)
	}
	respondJSON(w, http.StatusOK, info)
}

func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	if user == nil || user.Role != "admin" {
		respondError(w, http.StatusForbidden, "system administrator required")
		return
	}
	if !a.updateEnabled() {
		respondError(w, http.StatusServiceUnavailable, "online update is not configured")
		return
	}

	info, err := a.systemVersion(r.Context())
	if err != nil {
		respondError(w, http.StatusBadGateway, "failed to check latest release")
		return
	}
	if !info.UpdateAvailable {
		respondError(w, http.StatusConflict, "already on the latest version")
		return
	}

	backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
	if err != nil {
		a.log.Error("backup database before update", "error", err)
		respondError(w, http.StatusInternalServerError, "failed to back up database")
		return
	}
	a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
	respondJSON(w, http.StatusAccepted, map[string]any{
		"ok":             true,
		"currentVersion": info.CurrentVersion,
		"targetVersion":  info.LatestVersion,
		"message":        "更新已启动，服务会在完成后自动恢复",
	})
	a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
}

func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
	current := strings.TrimSpace(a.config().AppVersion)
	if current == "" {
		current = BuildVersion
	}
	info := systemVersionInfo{
		CurrentVersion: current,
		CurrentCommit:  strings.TrimSpace(BuildCommit),
		BuildDate:      strings.TrimSpace(BuildDate),
		UpdateEnabled:  a.updateEnabled(),
	}

	release, err := a.fetchLatestRelease(ctx)
	if err != nil {
		return info, err
	}
	info.LatestVersion = strings.TrimSpace(release.TagName)
	info.LatestName = strings.TrimSpace(release.Name)
	info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
	info.ReleaseNotes = strings.TrimSpace(release.Body)
	if !release.PublishedAt.IsZero() {
		info.PublishedAt = &release.PublishedAt
	}
	info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
	return info, nil
}

func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
	endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
	parsed, err := url.Parse(endpoint)
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		return githubRelease{}, errors.New("invalid release API URL")
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
	if err != nil {
		return githubRelease{}, err
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
	client := &http.Client{
		Timeout: 8 * time.Second,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	resp, err := client.Do(req)
	if err != nil {
		return githubRelease{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
		return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
	}
	var release githubRelease
	if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
		return githubRelease{}, err
	}
	if strings.TrimSpace(release.TagName) == "" {
		return githubRelease{}, errors.New("release API returned an empty tag")
	}
	return release, nil
}

func (a *App) updateEnabled() bool {
	return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}

func (a *App) triggerUpdateService(ctx context.Context) error {
	parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		return errors.New("invalid update service URL")
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
	client := &http.Client{
		Timeout: 10 * time.Minute,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("update service returned %s", resp.Status)
	}
	return nil
}

func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
	go func() {
		// Let the accepted response reach the browser before Watchtower replaces this container.
		time.Sleep(250 * time.Millisecond)
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
		defer cancel()
		if err := a.triggerUpdateService(ctx); err != nil {
			a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
		}
	}()
}

func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
	backupDir := filepath.Join(a.config().DataDir, "backups")
	if err := os.MkdirAll(backupDir, 0o700); err != nil {
		return "", err
	}
	backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
	quotedPath := strings.ReplaceAll(backupPath, "'", "''")
	if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
		return "", err
	}
	if err := pruneUpdateBackups(backupDir, 5); err != nil {
		a.log.Warn("prune update backups", "error", err)
	}
	return backupPath, nil
}

func pruneUpdateBackups(dir string, keep int) error {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return err
	}
	type backupFile struct {
		path    string
		modTime time.Time
	}
	backups := make([]backupFile, 0, len(entries))
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
			continue
		}
		info, err := entry.Info()
		if err != nil {
			return err
		}
		backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
	}
	sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
	if keep < 0 {
		keep = 0
	}
	if len(backups) <= keep {
		return nil
	}
	for _, backup := range backups[keep:] {
		if err := os.Remove(backup.path); err != nil {
			return err
		}
	}
	return nil
}

var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)

func versionIsNewer(latest, current string) bool {
	latestParts, latestPrerelease, latestOK := parseVersion(latest)
	currentParts, currentPrerelease, currentOK := parseVersion(current)
	if !latestOK {
		return false
	}
	if !currentOK {
		return true
	}
	for i := 0; i < len(latestParts); i++ {
		if latestParts[i] != currentParts[i] {
			return latestParts[i] > currentParts[i]
		}
	}
	return currentPrerelease != "" && latestPrerelease == ""
}

func parseVersion(value string) ([3]int, string, bool) {
	match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
	if match == nil {
		return [3]int{}, "", false
	}
	var parts [3]int
	for i := 0; i < 3; i++ {
		if match[i+1] == "" {
			continue
		}
		part, err := strconv.Atoi(match[i+1])
		if err != nil {
			return [3]int{}, "", false
		}
		parts[i] = part
	}
	return parts, match[4], true
}
