package app

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	stdRuntime "runtime"
	"strconv"
	"strings"
	"time"

	"GoNavi-Wails/internal/connection"
	"GoNavi-Wails/internal/logger"

	wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)

const (
	updateRepo                  = "Syngnat/GoNavi"
	updateAPIURL                = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
	updateChecksumAsset         = "SHA256SUMS"
	updateDownloadProgressEvent = "update:download-progress"
)

var (
	updateFetchLatestRelease = fetchLatestRelease
	updateFetchReleaseSHA256 = fetchReleaseSHA256
	updateLogCheckError      = func(err error) { logger.Error(err, "检查更新失败") }
)

type updateState struct {
	lastCheck   *UpdateInfo
	downloading bool
	staged      *stagedUpdate
}

type UpdateInfo struct {
	HasUpdate       bool   `json:"hasUpdate"`
	CurrentVersion  string `json:"currentVersion"`
	LatestVersion   string `json:"latestVersion"`
	ReleaseName     string `json:"releaseName"`
	ReleaseNotesURL string `json:"releaseNotesUrl"`
	AssetName       string `json:"assetName"`
	AssetURL        string `json:"assetUrl"`
	AssetSize       int64  `json:"assetSize"`
	SHA256          string `json:"sha256"`
	Downloaded      bool   `json:"downloaded"`
	DownloadPath    string `json:"downloadPath,omitempty"`
}

type AppInfo struct {
	Version      string `json:"version"`
	Author       string `json:"author"`
	RepoURL      string `json:"repoUrl,omitempty"`
	IssueURL     string `json:"issueUrl,omitempty"`
	ReleaseURL   string `json:"releaseUrl,omitempty"`
	CommunityURL string `json:"communityUrl,omitempty"`
	BuildTime    string `json:"buildTime,omitempty"`
}

type updateDownloadResult struct {
	Info           UpdateInfo `json:"info"`
	DownloadPath   string     `json:"downloadPath,omitempty"`
	InstallLogPath string     `json:"installLogPath,omitempty"`
	InstallTarget  string     `json:"installTarget,omitempty"`
	Platform       string     `json:"platform"`
	AutoRelaunch   bool       `json:"autoRelaunch"`
}

type updateDownloadProgressPayload struct {
	Status     string  `json:"status"`
	Percent    float64 `json:"percent"`
	Downloaded int64   `json:"downloaded"`
	Total      int64   `json:"total"`
	Message    string  `json:"message,omitempty"`
}

type stagedUpdate struct {
	Version        string
	AssetName      string
	FilePath       string
	StagedDir      string
	InstallLogPath string
}

type githubRelease struct {
	TagName    string        `json:"tag_name"`
	Name       string        `json:"name"`
	HTMLURL    string        `json:"html_url"`
	Prerelease bool          `json:"prerelease"`
	Assets     []githubAsset `json:"assets"`
}

type githubAsset struct {
	Name               string `json:"name"`
	BrowserDownloadURL string `json:"browser_download_url"`
	Size               int64  `json:"size"`
}

func (a *App) CheckForUpdates() connection.QueryResult {
	return a.checkForUpdates(true)
}

func (a *App) CheckForUpdatesSilently() connection.QueryResult {
	return a.checkForUpdates(false)
}

func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
	info, err := fetchLatestUpdateInfo()
	if err != nil {
		if logFailure {
			updateLogCheckError(err)
		}
		return connection.QueryResult{Success: false, Message: err.Error()}
	}

	var currentStaged *stagedUpdate
	a.updateMu.Lock()
	currentStaged = a.updateState.staged
	a.updateMu.Unlock()

	if info.HasUpdate {
		reusable := resolveReusableStagedUpdate(info, currentStaged)
		if reusable != nil {
			info.Downloaded = true
			info.DownloadPath = reusable.FilePath
			currentStaged = reusable
		} else if currentStaged != nil && currentStaged.Version != info.LatestVersion {
			currentStaged = nil
		}
	} else {
		currentStaged = nil
	}

	a.updateMu.Lock()
	a.updateState.lastCheck = &info
	a.updateState.staged = currentStaged
	a.updateMu.Unlock()

	msg := "已是最新版本"
	if info.HasUpdate {
		msg = fmt.Sprintf("发现新版本：%s", info.LatestVersion)
	}
	return connection.QueryResult{Success: true, Message: msg, Data: info}
}

func (a *App) GetAppInfo() connection.QueryResult {
	info := AppInfo{
		Version:      getCurrentVersion(),
		Author:       getCurrentAuthor(),
		RepoURL:      "https://github.com/" + updateRepo,
		IssueURL:     "https://github.com/" + updateRepo + "/issues",
		ReleaseURL:   "https://github.com/" + updateRepo + "/releases",
		CommunityURL: "https://aibook.ren",
		BuildTime:    strings.TrimSpace(AppBuildTime),
	}
	return connection.QueryResult{Success: true, Message: "OK", Data: info}
}

func (a *App) DownloadUpdate() connection.QueryResult {
	a.updateMu.Lock()
	if a.updateState.downloading {
		a.updateMu.Unlock()
		return connection.QueryResult{Success: false, Message: "更新包正在下载中，请稍后重试"}
	}
	info := a.updateState.lastCheck
	if info == nil {
		a.updateMu.Unlock()
		return connection.QueryResult{Success: false, Message: "请先检查更新"}
	}
	if !info.HasUpdate {
		a.updateMu.Unlock()
		return connection.QueryResult{Success: false, Message: "当前已是最新版本"}
	}
	if info.AssetURL == "" || info.AssetName == "" {
		a.updateMu.Unlock()
		return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
	}
	staged := resolveReusableStagedUpdate(*info, a.updateState.staged)
	if staged != nil {
		a.updateState.staged = staged
		a.updateMu.Unlock()
		return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
	}
	a.updateState.staged = nil
	a.updateState.downloading = true
	a.updateMu.Unlock()

	a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "")
	result := a.downloadAndStageUpdate(*info)

	a.updateMu.Lock()
	a.updateState.downloading = false
	a.updateMu.Unlock()

	return result
}

func (a *App) InstallUpdateAndRestart() connection.QueryResult {
	a.updateMu.Lock()
	staged := a.updateState.staged
	if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" {
		staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
	}
	a.updateMu.Unlock()
	if staged == nil {
		return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
	}

	if err := launchUpdateScript(staged); err != nil {
		logger.Error(err, "启动更新脚本失败")
		msg := err.Error()
		if staged.InstallLogPath != "" {
			msg = fmt.Sprintf("%s（更新日志：%s）", msg, staged.InstallLogPath)
		}
		return connection.QueryResult{
			Success: false,
			Message: msg,
			Data: map[string]any{
				"logPath": staged.InstallLogPath,
			},
		}
	}

	go func() {
		time.Sleep(300 * time.Millisecond)
		wailsRuntime.Quit(a.ctx)
		// 兜底退出，避免某些平台/窗口状态下 Quit 未真正结束进程，导致更新脚本一直等待。
		time.Sleep(2 * time.Second)
		os.Exit(0)
	}()

	msg := "更新已开始安装"
	if staged.InstallLogPath != "" {
		msg = fmt.Sprintf("更新已开始安装，日志路径：%s", staged.InstallLogPath)
	}
	return connection.QueryResult{
		Success: true,
		Message: msg,
		Data: map[string]any{
			"logPath": staged.InstallLogPath,
		},
	}
}

func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
	a.updateMu.Lock()
	staged := a.updateState.staged
	a.updateMu.Unlock()
	if staged == nil {
		return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
	}
	assetPath := strings.TrimSpace(staged.FilePath)
	if assetPath == "" {
		return connection.QueryResult{Success: false, Message: "更新包路径为空"}
	}
	dirPath := strings.TrimSpace(filepath.Dir(assetPath))
	if dirPath == "" || dirPath == "." {
		return connection.QueryResult{Success: false, Message: "无法解析更新目录"}
	}
	if stat, err := os.Stat(dirPath); err != nil || !stat.IsDir() {
		return connection.QueryResult{Success: false, Message: "更新目录不存在或不可访问"}
	}

	var cmd *exec.Cmd
	switch stdRuntime.GOOS {
	case "darwin":
		cmd = exec.Command("open", dirPath)
	case "windows":
		cmd = exec.Command("explorer", dirPath)
	case "linux":
		cmd = exec.Command("xdg-open", dirPath)
	default:
		return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前平台暂不支持打开目录：%s", stdRuntime.GOOS)}
	}
	if err := cmd.Start(); err != nil {
		logger.Error(err, "打开更新目录失败")
		return connection.QueryResult{Success: false, Message: fmt.Sprintf("打开更新目录失败：%v", err)}
	}
	return connection.QueryResult{
		Success: true,
		Message: fmt.Sprintf("已打开安装目录：%s", dirPath),
		Data: map[string]any{
			"path": dirPath,
		},
	}
}

func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
	workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
	if workspaceDir == "" {
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录")
		return connection.QueryResult{Success: false, Message: "无法确定当前应用目录，无法下载更新"}
	}
	if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
		errMsg := fmt.Sprintf("无法访问应用目录：%s", workspaceDir)
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
		return connection.QueryResult{Success: false, Message: errMsg}
	}

	// 使用版本号命名的工作目录，便于识别和调试
	stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
	// 清理可能残留的旧目录（上次下载失败后未清理）
	// Windows 上文件可能被杀毒软件/索引服务占用，需要重试
	for retry := 0; retry < 5; retry++ {
		err := os.RemoveAll(stagedDir)
		if err == nil {
			break
		}
		if retry < 4 {
			time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
		} else {
			// 最后一次仍然失败，换一个带时间戳的目录名避免冲突
			stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
		}
	}
	if err := os.MkdirAll(stagedDir, 0o755); err != nil {
		errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录：%s", stagedDir)
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
		return connection.QueryResult{Success: false, Message: errMsg}
	}

	// macOS 下载包放在桌面版本目录根级；其他平台继续放在 staging 目录。
	assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, info.AssetName)
	actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) {
		reportTotal := total
		if reportTotal <= 0 {
			reportTotal = info.AssetSize
		}
		a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "")
	})
	if err != nil {
		_ = os.Remove(assetPath)
		_ = os.RemoveAll(stagedDir)
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error())
		return connection.QueryResult{Success: false, Message: err.Error()}
	}

	if info.SHA256 == "" {
		_ = os.Remove(assetPath)
		_ = os.RemoveAll(stagedDir)
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值（SHA256SUMS）")
		return connection.QueryResult{Success: false, Message: "缺少更新包校验值（SHA256SUMS）"}
	}
	if !strings.EqualFold(info.SHA256, actualHash) {
		_ = os.Remove(assetPath)
		_ = os.RemoveAll(stagedDir)
		a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败，请重试")
		return connection.QueryResult{Success: false, Message: "更新包校验失败，请重试"}
	}

	staged := &stagedUpdate{
		Version:        info.LatestVersion,
		AssetName:      info.AssetName,
		FilePath:       assetPath,
		StagedDir:      stagedDir,
		InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
	}
	info.Downloaded = true
	info.DownloadPath = assetPath
	a.updateMu.Lock()
	a.updateState.staged = staged
	a.updateMu.Unlock()

	a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "")
	return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)}
}

func fetchLatestUpdateInfo() (UpdateInfo, error) {
	release, err := updateFetchLatestRelease()
	if err != nil {
		return UpdateInfo{}, err
	}

	currentVersion := getCurrentVersion()
	latestVersion := normalizeVersion(release.TagName)
	if latestVersion == "" {
		return UpdateInfo{}, errors.New("无法解析最新版本号")
	}

	hasUpdate := compareVersion(currentVersion, latestVersion) < 0
	if !hasUpdate {
		return UpdateInfo{
			HasUpdate:       false,
			CurrentVersion:  currentVersion,
			LatestVersion:   latestVersion,
			ReleaseName:     release.Name,
			ReleaseNotesURL: release.HTMLURL,
		}, nil
	}

	assetVersion := strings.TrimSpace(release.TagName)
	if assetVersion == "" {
		assetVersion = latestVersion
	}
	assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion)
	if err != nil {
		return UpdateInfo{}, err
	}
	asset, err := findReleaseAsset(release.Assets, assetName)
	if err != nil {
		return UpdateInfo{}, err
	}

	hashMap, err := updateFetchReleaseSHA256(release.Assets)
	if err != nil {
		return UpdateInfo{}, err
	}
	sha256Value := strings.TrimSpace(hashMap[assetName])
	if sha256Value == "" {
		return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包")
	}
	return UpdateInfo{
		HasUpdate:       hasUpdate,
		CurrentVersion:  currentVersion,
		LatestVersion:   latestVersion,
		ReleaseName:     release.Name,
		ReleaseNotesURL: release.HTMLURL,
		AssetName:       asset.Name,
		AssetURL:        asset.BrowserDownloadURL,
		AssetSize:       asset.Size,
		SHA256:          sha256Value,
	}, nil
}

func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() {
	original := updateFetchLatestRelease
	updateFetchLatestRelease = next
	return func() {
		updateFetchLatestRelease = original
	}
}

func swapUpdateFetchReleaseSHA256(next func([]githubAsset) (map[string]string, error)) func() {
	original := updateFetchReleaseSHA256
	updateFetchReleaseSHA256 = next
	return func() {
		updateFetchReleaseSHA256 = original
	}
}

func swapUpdateCheckErrorLogger(next func(error)) func() {
	original := updateLogCheckError
	updateLogCheckError = next
	return func() {
		updateLogCheckError = original
	}
}

func getCurrentAuthor() string {
	if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" {
		return env
	}
	parts := strings.Split(updateRepo, "/")
	if len(parts) > 0 {
		return parts[0]
	}
	return ""
}

func fetchLatestRelease() (*githubRelease, error) {
	client := newHTTPClientWithGlobalProxy(15 * time.Second)
	req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "GoNavi-Updater")
	req.Header.Set("Accept", "application/vnd.github+json")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("检查更新失败：HTTP %d", resp.StatusCode)
	}

	var release githubRelease
	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
		return nil, err
	}
	return &release, nil
}

func expectedAssetName(goos, goarch, version string) (string, error) {
	executablePath := ""
	if goos == "linux" {
		if path, err := os.Executable(); err == nil {
			if resolved, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil && strings.TrimSpace(resolved) != "" {
				path = resolved
			}
			executablePath = path
		}
	}
	return expectedAssetNameForExecutable(goos, goarch, version, executablePath)
}

func expectedAssetNameForExecutable(goos, goarch, version, executablePath string) (string, error) {
	version = strings.TrimSpace(version)
	version = strings.TrimPrefix(version, "v")
	version = strings.TrimPrefix(version, "V")
	if version == "" {
		return "", errors.New("无法解析发布版本号")
	}

	switch goos {
	case "windows":
		if goarch == "amd64" {
			return fmt.Sprintf("GoNavi-%s-Windows-Amd64.exe", version), nil
		}
		if goarch == "arm64" {
			return fmt.Sprintf("GoNavi-%s-Windows-Arm64.exe", version), nil
		}
	case "darwin":
		if goarch == "amd64" {
			return fmt.Sprintf("GoNavi-%s-MacOS-Amd64.dmg", version), nil
		}
		if goarch == "arm64" {
			return fmt.Sprintf("GoNavi-%s-MacOS-Arm64.dmg", version), nil
		}
	case "linux":
		if goarch == "amd64" {
			return fmt.Sprintf("GoNavi-%s-Linux-Amd64%s.tar.gz", version, resolveLinuxReleaseArtifactSuffix(executablePath)), nil
		}
	}
	return "", fmt.Errorf("当前平台暂不支持在线更新：%s/%s", goos, goarch)
}

func resolveLinuxReleaseArtifactSuffix(executablePath string) string {
	normalizedPath := strings.ToLower(strings.TrimSpace(executablePath))
	if normalizedPath == "" {
		return ""
	}
	normalizedPath = strings.ReplaceAll(normalizedPath, "\\", "/")
	compactPath := strings.ReplaceAll(normalizedPath, "_", "")
	compactPath = strings.ReplaceAll(compactPath, "-", "")
	if strings.Contains(normalizedPath, "webkit41") || strings.Contains(compactPath, "webkit241") || strings.Contains(compactPath, "webkit41") {
		return "-WebKit41"
	}
	return ""
}

func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
	for _, asset := range assets {
		if asset.Name == name {
			return &asset, nil
		}
	}
	return nil, fmt.Errorf("未找到更新包：%s", name)
}

func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
	var checksumURL string
	for _, asset := range assets {
		if strings.EqualFold(asset.Name, updateChecksumAsset) || strings.Contains(strings.ToLower(asset.Name), "sha256sums") {
			checksumURL = asset.BrowserDownloadURL
			break
		}
	}
	if checksumURL == "" {
		return nil, errors.New("Release 未提供 SHA256SUMS")
	}

	client := newHTTPClientWithGlobalProxy(15 * time.Second)
	req, err := http.NewRequest(http.MethodGet, checksumURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "GoNavi-Updater")
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("下载 SHA256SUMS 失败：HTTP %d", resp.StatusCode)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	return parseSHA256Sums(string(body)), nil
}

func parseSHA256Sums(content string) map[string]string {
	result := make(map[string]string)
	lines := strings.Split(content, "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) < 2 {
			continue
		}
		hash := fields[0]
		name := fields[len(fields)-1]
		name = strings.TrimPrefix(name, "*")
		name = strings.TrimPrefix(name, "./")
		result[name] = hash
	}
	return result
}

type downloadProgressWriter struct {
	total      int64
	written    int64
	lastEmit   time.Time
	emitEvery  time.Duration
	onProgress func(downloaded, total int64)
}

func (w *downloadProgressWriter) Write(p []byte) (int, error) {
	n := len(p)
	if n == 0 {
		return 0, nil
	}
	w.written += int64(n)
	if w.onProgress == nil {
		return n, nil
	}
	now := time.Now()
	if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= w.emitEvery || (w.total > 0 && w.written >= w.total) {
		w.lastEmit = now
		w.onProgress(w.written, w.total)
	}
	return n, nil
}

func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) {
	return downloadFileWithHashWithTimeout(url, filePath, onProgress, 10*time.Minute)
}

func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downloaded, total int64), timeout time.Duration) (string, error) {
	if timeout <= 0 {
		timeout = 10 * time.Minute
	}
	client := newHTTPClientWithGlobalProxy(timeout)
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("User-Agent", "GoNavi-Updater")

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("下载更新包失败：HTTP %d", resp.StatusCode)
	}

	// Windows 上旧文件可能被杀毒软件/索引服务占用，先尝试删除并重试
	_ = os.Remove(filePath)
	var out *os.File
	for retry := 0; retry < 5; retry++ {
		out, err = os.Create(filePath)
		if err == nil {
			break
		}
		if retry < 4 {
			time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
		}
	}
	if err != nil {
		return "", fmt.Errorf("更新下载失败，文件被占用：%w", err)
	}

	hasher := sha256.New()
	total := resp.ContentLength
	progressWriter := &downloadProgressWriter{
		total:      total,
		emitEvery:  120 * time.Millisecond,
		onProgress: onProgress,
	}
	writers := []io.Writer{out, hasher, progressWriter}
	if onProgress != nil {
		onProgress(0, total)
	}
	if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
		out.Close()
		return "", err
	}
	if onProgress != nil {
		onProgress(progressWriter.written, total)
	}

	// 显式 Sync + Close，确保数据落盘且文件句柄释放
	if err := out.Sync(); err != nil {
		out.Close()
		return "", err
	}
	if err := out.Close(); err != nil {
		return "", err
	}

	return hex.EncodeToString(hasher.Sum(nil)), nil
}

func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDownloadResult {
	result := updateDownloadResult{
		Info:          info,
		Platform:      stdRuntime.GOOS,
		InstallTarget: resolveUpdateInstallTarget(),
		AutoRelaunch:  true,
	}
	if staged != nil {
		result.DownloadPath = staged.FilePath
		result.InstallLogPath = staged.InstallLogPath
	}
	return result
}

func buildUpdateInstallLogPath(baseDir string) string {
	platform := stdRuntime.GOOS
	if platform == "darwin" {
		platform = "macos"
	}
	logDir := strings.TrimSpace(baseDir)
	if logDir == "" {
		logDir = os.TempDir()
	}
	return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano()))
}

func sanitizeVersionForPath(version string) string {
	trimmed := strings.TrimSpace(version)
	if trimmed == "" {
		return "latest"
	}

	var builder strings.Builder
	lastDash := false
	for _, r := range trimmed {
		isAllowed := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
		if isAllowed {
			builder.WriteRune(r)
			lastDash = false
			continue
		}
		if !lastDash {
			builder.WriteRune('-')
			lastDash = true
		}
	}

	result := strings.Trim(builder.String(), "-")
	if result == "" {
		return "latest"
	}
	return result
}

func resolveLegacyUpdateWorkspaceDir() string {
	return filepath.Join(os.TempDir(), "gonavi-updates")
}

func resolveUpdateWorkspaceDir(version string) string {
	// 默认使用系统临时目录作为更新工作区，避免目录权限与锁冲突。
	// macOS 用户要求更新包默认保存在桌面：Desktop/GoNavi-<version>/。
	if stdRuntime.GOOS == "darwin" {
		homeDir, err := os.UserHomeDir()
		if err == nil && strings.TrimSpace(homeDir) != "" {
			desktopDir := filepath.Join(homeDir, "Desktop")
			if st, statErr := os.Stat(desktopDir); statErr == nil && st.IsDir() {
				return filepath.Join(desktopDir, fmt.Sprintf("GoNavi-%s", sanitizeVersionForPath(version)))
			}
		}
	}
	return resolveLegacyUpdateWorkspaceDir()
}

func resolveUpdateAssetPath(workspaceDir string, stagedDir string, assetName string) string {
	name := strings.TrimSpace(assetName)
	if stdRuntime.GOOS == "darwin" {
		return filepath.Join(workspaceDir, name)
	}
	return filepath.Join(stagedDir, name)
}

func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
	path := strings.TrimSpace(filePath)
	if path == "" {
		return false
	}
	stat, err := os.Stat(path)
	if err != nil || stat.IsDir() {
		return false
	}
	if expectedSize > 0 && stat.Size() != expectedSize {
		return false
	}
	return true
}

func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
	version := strings.TrimSpace(info.LatestVersion)
	assetName := strings.TrimSpace(info.AssetName)
	if version == "" || assetName == "" {
		return nil
	}

	if current != nil && strings.TrimSpace(current.Version) == version {
		currentPath := strings.TrimSpace(current.FilePath)
		if isExistingDownloadedAsset(currentPath, info.AssetSize) {
			if strings.TrimSpace(current.InstallLogPath) == "" {
				current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
			}
			return current
		}
	}

	type pathCandidate struct {
		workspaceDir string
		stagedDir    string
		assetPath    string
	}
	stagedDirName := fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, version)
	workspaceCandidates := []string{
		resolveUpdateWorkspaceDir(version),
		resolveLegacyUpdateWorkspaceDir(),
	}
	seenWorkspace := make(map[string]struct{}, len(workspaceCandidates))
	candidates := make([]pathCandidate, 0, 4)
	for _, workspaceDir := range workspaceCandidates {
		workspaceDir = strings.TrimSpace(workspaceDir)
		if workspaceDir == "" {
			continue
		}
		if _, exists := seenWorkspace[workspaceDir]; exists {
			continue
		}
		seenWorkspace[workspaceDir] = struct{}{}

		stagedDir := filepath.Join(workspaceDir, stagedDirName)
		assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, assetName)
		candidates = append(candidates, pathCandidate{
			workspaceDir: workspaceDir,
			stagedDir:    stagedDir,
			assetPath:    assetPath,
		})
		legacyAssetPath := filepath.Join(stagedDir, assetName)
		if legacyAssetPath != assetPath {
			candidates = append(candidates, pathCandidate{
				workspaceDir: workspaceDir,
				stagedDir:    stagedDir,
				assetPath:    legacyAssetPath,
			})
		}
	}

	for _, candidate := range candidates {
		if !isExistingDownloadedAsset(candidate.assetPath, info.AssetSize) {
			continue
		}
		return &stagedUpdate{
			Version:        version,
			AssetName:      assetName,
			FilePath:       candidate.assetPath,
			StagedDir:      candidate.stagedDir,
			InstallLogPath: buildUpdateInstallLogPath(candidate.workspaceDir),
		}
	}

	return nil
}

func resolveUpdateInstallTarget() string {
	exePath, err := os.Executable()
	if err != nil {
		return ""
	}
	exePath, _ = filepath.EvalSymlinks(exePath)
	if stdRuntime.GOOS == "darwin" {
		return resolveMacUpdateTarget(exePath)
	}
	return exePath
}

func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) {
	if a.ctx == nil {
		return
	}
	payload := updateDownloadProgressPayload{
		Status:     status,
		Percent:    0,
		Downloaded: downloaded,
		Total:      total,
		Message:    strings.TrimSpace(message),
	}
	if total > 0 {
		payload.Percent = math.Min(100, (float64(downloaded)/float64(total))*100)
	}
	if status == "done" && payload.Percent < 100 {
		payload.Percent = 100
	}
	wailsRuntime.EventsEmit(a.ctx, updateDownloadProgressEvent, payload)
}

func launchUpdateScript(staged *stagedUpdate) error {
	exePath, err := os.Executable()
	if err != nil {
		return err
	}
	exePath, _ = filepath.EvalSymlinks(exePath)
	pid := os.Getpid()

	switch stdRuntime.GOOS {
	case "windows":
		return launchWindowsUpdate(staged, exePath, pid)
	case "darwin":
		return launchMacUpdate(staged, exePath, pid)
	case "linux":
		return launchLinuxUpdate(staged, exePath, pid)
	default:
		return fmt.Errorf("当前平台暂不支持更新安装：%s", stdRuntime.GOOS)
	}
}

func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
	scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
	logPath := strings.TrimSpace(staged.InstallLogPath)
	if logPath == "" {
		logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
		staged.InstallLogPath = logPath
	}
	content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid)
	if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
		return err
	}

	logger.Infof("启动 Windows 更新脚本：target=%s script=%s log=%s", targetExe, scriptPath, logPath)
	cmd := buildWindowsLaunchCommand(scriptPath)
	if err := cmd.Start(); err != nil {
		return err
	}
	if cmd.Process != nil {
		if err := cmd.Process.Release(); err != nil {
			logger.Warnf("释放 Windows 更新脚本进程句柄失败：%v", err)
		}
	}
	return nil
}

func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
	targetApp := resolveMacUpdateTarget(targetExe)
	mountDir := filepath.Join(staged.StagedDir, "mnt")
	if err := os.MkdirAll(mountDir, 0o755); err != nil {
		return err
	}
	logPath := strings.TrimSpace(staged.InstallLogPath)
	if logPath == "" {
		logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
		staged.InstallLogPath = logPath
	}

	scriptPath := filepath.Join(staged.StagedDir, "update.sh")
	content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
	if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
		return err
	}

	cmd := exec.Command("/bin/bash", scriptPath)
	logger.Infof("启动 macOS 更新脚本：target=%s script=%s log=%s", targetApp, scriptPath, logPath)
	return cmd.Start()
}

func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
	scriptPath := filepath.Join(staged.StagedDir, "update.sh")
	content := buildLinuxScript(staged.FilePath, targetExe, staged.StagedDir, pid)
	if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
		return err
	}

	cmd := exec.Command("/bin/sh", scriptPath)
	return cmd.Start()
}

func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
	script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0

call :log updater started
if not exist "%SOURCE%" (
  call :log source file not found: %SOURCE%
  exit /b 1
)

for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="

if /I "%SOURCE_EXT%"==".zip" (
  set "EXTRACT_DIR=%STAGED%\_extract"
  if exist "%EXTRACT_DIR%" (
    rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
  )
  mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
  powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
  if !ERRORLEVEL! NEQ 0 (
    call :log expand zip failed: %SOURCE%
    exit /b 1
  )
  if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
    set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
  ) else (
    for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
      if not defined SOURCE_EXE (
        set "SOURCE_EXE=%%~fF"
      )
    )
  )
  if not defined SOURCE_EXE (
    call :log no executable found in portable zip: %SOURCE%
    exit /b 1
  )
) else (
  set "SOURCE_EXE=%SOURCE%"
)

:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
  if !WAIT_PID_SECONDS! GEQ 90 (
    call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
    exit /b 1
  )
  timeout /t 1 /nobreak >nul
  set /a WAIT_PID_SECONDS+=1
  goto waitloop
)
call :log host process exited

rem -- Win10 needs extra time for kernel to release exe file handles --
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace

set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
  copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
  if !ERRORLEVEL!==0 (
    del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
    goto move_done
  )
  call :log copy after rename failed, restoring old file
  move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)

call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done

copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done

set /a RETRY+=1
if !RETRY! LSS 15 (
  set /a WAIT=1
  if !RETRY! GEQ 3 set /a WAIT=2
  if !RETRY! GEQ 6 set /a WAIT=3
  if !RETRY! GEQ 9 set /a WAIT=5
  call :log waiting !WAIT! seconds before retry
  timeout /t !WAIT! /nobreak >nul
  goto move_retry
)

call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1

:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
  call :log cmd start failed, trying powershell Start-Process
  powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
  if !ERRORLEVEL! NEQ 0 (
    call :log relaunch failed
    exit /b 1
  )
)
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
call :log update finished
exit /b 0

:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
	return strings.NewReplacer(
		"__GONAVI_UPDATE_SOURCE__", source,
		"__GONAVI_UPDATE_TARGET__", target,
		"__GONAVI_UPDATE_STAGED__", stagedDir,
		"__GONAVI_UPDATE_LOG__", logPath,
		"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
	).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}

func buildWindowsLaunchCommand(scriptPath string) *exec.Cmd {
	cmd := exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
	configureWindowsUpdateCommand(cmd)
	return cmd
}

func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
	return fmt.Sprintf(`#!/bin/bash
set -euo pipefail
PID=%d
DMG="%s"
TARGET_APP="%s"
STAGED="%s"
MOUNT_DIR="%s"
LOG_FILE="%s"
TMP_APP="${TARGET_APP}.new"
BACKUP_APP="${TARGET_APP}.backup"
APP_BIN_NAME=$(basename "$TARGET_APP" .app)
APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME"

log() {
  echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE"
}

run_admin_replace() {
  /usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$TMP_APP" "$BACKUP_APP" "$APP_BIN_REL" "$LOG_FILE"
on run argv
  set srcPath to item 1 of argv
  set dstPath to item 2 of argv
  set tmpPath to item 3 of argv
  set bakPath to item 4 of argv
  set binRel to item 5 of argv
  set logPath to item 6 of argv
  set cmd to "set -eu; " & ¬
    "rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬
    "/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬
    "if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬
    "xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬
    "if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬
    "mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬
    "rm -rf " & quoted form of bakPath & "; " & ¬
    "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true"
  do shell script cmd with administrator privileges
end run
APPLESCRIPT
}

replace_app_direct() {
  rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
  /usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1
  if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then
    log "tmp app binary missing: $TMP_APP/$APP_BIN_REL"
    return 1
  fi
  xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true
  if [ -d "$TARGET_APP" ]; then
    mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1
  fi
  if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
    log "move new app failed, trying rollback"
    rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
    if [ -d "$BACKUP_APP" ]; then
      mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
    fi
    return 1
  fi
  rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
  xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
  return 0
}

relaunch_app() {
  if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
    return 0
  fi
  log "open -n failed, trying binary launch"
  "$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
  return 0
}

log "updater started"
while kill -0 $PID 2>/dev/null; do
  sleep 1
done
log "host process exited"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1 || true)
if [ -z "$APP_SRC" ]; then
  log "no .app found inside dmg"
  hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
  exit 1
fi

log "install target: $TARGET_APP"
if ! replace_app_direct; then
  log "direct replace failed, trying admin replace"
  run_admin_replace >>"$LOG_FILE" 2>&1
fi

if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then
  log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL"
  hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
  exit 1
fi

hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true
relaunch_app
log "relaunch requested"
	`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath)
}

func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
	return fmt.Sprintf(`#!/bin/bash
set -e
PID=%d
ARCHIVE="%s"
TARGET="%s"
STAGED="%s"
while kill -0 $PID 2>/dev/null; do
  sleep 1
done
TMPDIR=$(mktemp -d)
tar -xzf "$ARCHIVE" -C "$TMPDIR"
TARGET_NAME="$(basename "$TARGET")"
NEWBIN="$TMPDIR/$TARGET_NAME"
if [ ! -f "$NEWBIN" ]; then
  NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
  NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
  exit 1
fi
cp -f "$NEWBIN" "$TARGET"
chmod +x "$TARGET"
rm -rf "$TMPDIR" "$ARCHIVE" "$STAGED"
"$TARGET" &
`, pid, tarPath, targetExe, stagedDir)
}

func detectMacAppPath(exePath string) string {
	parts := strings.Split(exePath, string(filepath.Separator))
	for i := len(parts) - 1; i >= 0; i-- {
		if strings.HasSuffix(parts[i], ".app") {
			appPath := filepath.Join(parts[:i+1]...)
			// 确保返回绝对路径
			if !filepath.IsAbs(appPath) {
				appPath = string(filepath.Separator) + appPath
			}
			return appPath
		}
	}
	return ""
}

func resolveMacUpdateTarget(exePath string) string {
	targetApp := detectMacAppPath(exePath)
	if targetApp == "" {
		return "/Applications/GoNavi.app"
	}
	targetApp = filepath.Clean(targetApp)
	// Gatekeeper App Translocation 路径不可用于稳定覆盖更新，统一回退到 /Applications。
	if strings.Contains(targetApp, string(filepath.Separator)+"AppTranslocation"+string(filepath.Separator)) {
		logger.Warnf("检测到 AppTranslocation 运行路径，更新目标回退至 /Applications/GoNavi.app：%s", targetApp)
		return "/Applications/GoNavi.app"
	}
	return targetApp
}

func normalizeVersion(version string) string {
	version = strings.TrimSpace(version)
	version = strings.TrimPrefix(version, "v")
	return version
}

func compareVersion(current, latest string) int {
	current = normalizeVersion(current)
	latest = normalizeVersion(latest)
	if current == "" {
		return -1
	}
	if current == latest {
		return 0
	}

	curParts := splitVersionParts(current)
	latParts := splitVersionParts(latest)
	max := len(curParts)
	if len(latParts) > max {
		max = len(latParts)
	}
	for i := 0; i < max; i++ {
		cur := 0
		lat := 0
		if i < len(curParts) {
			cur = curParts[i]
		}
		if i < len(latParts) {
			lat = latParts[i]
		}
		if cur < lat {
			return -1
		}
		if cur > lat {
			return 1
		}
	}
	return 0
}

func splitVersionParts(version string) []int {
	parts := strings.Split(version, ".")
	result := make([]int, 0, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part == "" {
			result = append(result, 0)
			continue
		}
		num := 0
		for _, ch := range part {
			if ch < '0' || ch > '9' {
				break
			}
			num = num*10 + int(ch-'0')
		}
		result = append(result, num)
	}
	return result
}
