package main

import (
	"archive/zip"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"

	"hamlog/internal/applog"
)

// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"

// UpdateInfo is the result of the version check.
type UpdateInfo struct {
	Current     string `json:"current"`      // this build's version (appVersion)
	Latest      string `json:"latest"`       // newest published release, "" if unknown
	Available   bool   `json:"available"`    // Latest > Current
	URL         string `json:"url"`          // release page to open (manual fallback)
	DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
}

// CheckForUpdate asks GitHub for the latest release and compares it to this
// build. Best effort — on any failure it reports "no update" so the app never
// nags about a check it couldn't complete.
func (a *App) CheckForUpdate() UpdateInfo {
	out := UpdateInfo{Current: appVersion}
	client := &http.Client{Timeout: 8 * time.Second}
	req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil)
	if err != nil {
		return out
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	resp, err := client.Do(req)
	if err != nil {
		applog.Printf("update: check failed: %v", err)
		return out
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return out // no release yet (404) or rate-limited — treat as up to date
	}
	var r struct {
		TagName string `json:"tag_name"`
		HTMLURL string `json:"html_url"`
		Assets  []struct {
			Name string `json:"name"`
			URL  string `json:"browser_download_url"`
		} `json:"assets"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return out
	}
	out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
	out.URL = r.HTMLURL
	out.Available = versionLess(appVersion, out.Latest)
	// Pick the auto-download asset: a bare Windows .exe (portable build) first,
	// else a .zip we can unpack. The frontend hands this straight to
	// DownloadAndApplyUpdate for a one-click in-app update.
	for _, as := range r.Assets {
		if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
			out.DownloadURL = as.URL
			break
		}
	}
	if out.DownloadURL == "" {
		for _, as := range r.Assets {
			if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
				out.DownloadURL = as.URL
				break
			}
		}
	}
	if out.Available {
		applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
	}
	return out
}

// versionLess reports whether version a is older than b. Compares dot-separated
// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0.
func versionLess(a, b string) bool {
	pa := strings.Split(a, ".")
	pb := strings.Split(b, ".")
	n := len(pa)
	if len(pb) > n {
		n = len(pb)
	}
	for i := 0; i < n; i++ {
		ai, bi := 0, 0
		if i < len(pa) {
			ai = leadingInt(pa[i])
		}
		if i < len(pb) {
			bi = leadingInt(pb[i])
		}
		if ai != bi {
			return ai < bi
		}
	}
	return false
}

// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
// the new process and quits this one.
func (a *App) DownloadAndApplyUpdate(url string) error {
	if strings.TrimSpace(url) == "" {
		return fmt.Errorf("no download URL")
	}
	exe, err := os.Executable()
	if err != nil {
		return fmt.Errorf("locate executable: %w", err)
	}
	dir := filepath.Dir(exe)

	// Download to a temp file next to the exe (same volume, so the rename-swap is
	// atomic and can't fail across drives).
	tmp := filepath.Join(dir, ".opslog-update.download")
	_ = os.Remove(tmp)
	if err := a.downloadWithProgress(url, tmp); err != nil {
		_ = os.Remove(tmp)
		return fmt.Errorf("download: %w", err)
	}

	// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
	newExe := tmp
	if strings.HasSuffix(strings.ToLower(url), ".zip") {
		extracted, xerr := extractExeFromZip(tmp, dir)
		_ = os.Remove(tmp)
		if xerr != nil {
			return fmt.Errorf("unpack: %w", xerr)
		}
		newExe = extracted
	}

	// Swap: rename the running exe out of the way (Windows allows renaming a
	// running image), move the new one into its place, then relaunch. Roll back if
	// the second rename fails so we never end up with no exe.
	oldExe := exe + ".old"
	_ = os.Remove(oldExe)
	if err := os.Rename(exe, oldExe); err != nil {
		_ = os.Remove(newExe)
		return fmt.Errorf("stage current exe: %w", err)
	}
	if err := os.Rename(newExe, exe); err != nil {
		_ = os.Rename(oldExe, exe) // roll back
		return fmt.Errorf("install new exe: %w", err)
	}
	// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
	// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
	// this?" — but since we launch the exe programmatically that prompt never shows,
	// and the launch is silently blocked. This is exactly why the relaunch failed.
	_ = os.Remove(exe + ":Zone.Identifier")
	applog.Printf("update: installed new exe, scheduling relaunch")

	// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
	// (so the single-instance mutex is free) and THEN starts the new exe. Launching
	// the new exe directly while we're still alive raced the mutex and often left
	// nothing running; waiting for our own exit first makes the restart reliable,
	// and the launcher outlives us.
	quoted := strings.ReplaceAll(exe, "'", "''")
	ps := fmt.Sprintf(
		"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
		os.Getpid(), quoted)
	cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("schedule relaunch: %w", err)
	}
	if a.ctx != nil {
		wruntime.Quit(a.ctx)
	} else {
		os.Exit(0)
	}
	return nil
}

// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
func (a *App) downloadWithProgress(url, dest string) error {
	client := &http.Client{Timeout: 10 * time.Minute}
	resp, err := client.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("HTTP %d", resp.StatusCode)
	}
	f, err := os.Create(dest)
	if err != nil {
		return err
	}
	defer f.Close()

	total := resp.ContentLength
	var read int64
	last := -1
	buf := make([]byte, 64*1024)
	emit := func(pct int) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "update:progress", pct)
		}
	}
	emit(0)
	for {
		n, rerr := resp.Body.Read(buf)
		if n > 0 {
			if _, werr := f.Write(buf[:n]); werr != nil {
				return werr
			}
			read += int64(n)
			if total > 0 {
				if pct := int(read * 100 / total); pct != last {
					last = pct
					emit(pct)
				}
			}
		}
		if rerr == io.EOF {
			break
		}
		if rerr != nil {
			return rerr
		}
	}
	emit(100)
	return nil
}

// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
// its path.
func extractExeFromZip(zipPath, dir string) (string, error) {
	zr, err := zip.OpenReader(zipPath)
	if err != nil {
		return "", err
	}
	defer zr.Close()
	for _, zf := range zr.File {
		if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
			continue
		}
		rc, err := zf.Open()
		if err != nil {
			return "", err
		}
		out := filepath.Join(dir, ".opslog-update.exe")
		f, err := os.Create(out)
		if err != nil {
			rc.Close()
			return "", err
		}
		_, cerr := io.Copy(f, rc)
		rc.Close()
		f.Close()
		if cerr != nil {
			return "", cerr
		}
		return out, nil
	}
	return "", fmt.Errorf("no .exe inside the archive")
}

// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
// the file may still be briefly locked, in which case the next launch gets it.
func cleanupOldUpdateBinary() {
	if exe, err := os.Executable(); err == nil {
		_ = os.Remove(exe + ".old")
	}
}

// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
func leadingInt(s string) int {
	s = strings.TrimSpace(s)
	end := 0
	for end < len(s) && s[end] >= '0' && s[end] <= '9' {
		end++
	}
	if end == 0 {
		return 0
	}
	n, _ := strconv.Atoi(s[:end])
	return n
}
