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"

// releasesPageURL is the same release, for people rather than for the updater:
// the API address above answers JSON, so it is not something to put in front of
// an operator who followed a link out of a QSL e-mail.
const releasesPageURL = "https://github.com/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.
	//
	// The staging name is UNIQUE, not a fixed ".old". With a fixed name, one
	// leftover that could not be deleted — an antivirus holding it open is the
	// usual reason — poisoned every later update: the rename replaces its target,
	// the target was locked, and the operator got "stage current exe: … Accès
	// refusé" for ever with no way out but deleting the file by hand.
	oldExe := fmt.Sprintf("%s.old-%d", exe, time.Now().UnixNano())
	var stageErr error
	staged := false
	// Retry briefly: a real-time scanner opens the file it has just seen written
	// and holds it for a moment, so the first attempt lands exactly in that window.
	for attempt := 0; attempt < 5; attempt++ {
		if stageErr = os.Rename(exe, oldExe); stageErr == nil {
			staged = true
			break
		}
		time.Sleep(time.Duration(150*(attempt+1)) * time.Millisecond)
	}

	if staged {
		if err := os.Rename(newExe, exe); err != nil {
			_ = os.Rename(oldExe, exe) // roll back
			return fmt.Errorf("install new exe: %w", err)
		}
	} else {
		// Could not rename our own running image at all. Some endpoint protection
		// (Bitdefender's ransomware remediation among them) blocks precisely that,
		// and no amount of retrying gets past it.
		//
		// So don't fight it: leave the new build beside the old one and let the
		// relaunch helper do the swap AFTER this process has exited, when the file
		// is no longer a running image. Reported by several operators, all with the
		// same "Accès refusé" on the staging rename.
		applog.Printf("update: cannot rename the running exe (%v) — deferring the swap to after exit", stageErr)
		pending := exe + ".new"
		_ = os.Remove(pending)
		if err := os.Rename(newExe, pending); err != nil {
			_ = os.Remove(newExe)
			return fmt.Errorf("stage new exe: %w (the folder %s must be writable, and an antivirus may be holding OpsLog.exe)", err, dir)
		}
		if err := a.scheduleDeferredSwap(exe, pending); err != nil {
			return err
		}
		if a.ctx != nil {
			wruntime.Quit(a.ctx)
		} else {
			os.Exit(0)
		}
		return nil
	}
	// 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")

	// THE NEW EXE STARTS ITSELF. No helper, no script.
	//
	// This used to go through a hidden PowerShell that waited for our process to
	// die and then launched the new image — which is, byte for byte, the shape of
	// a dropper: an unsigned binary replaces itself on disk, clears the
	// mark-of-the-web, and spawns a windowless PowerShell that starts another
	// executable. Windows Defender's machine-learning model reads that shape and
	// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
	// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
	//
	// The wait it existed for is not needed: --post-update already makes the new
	// instance patient with the single-instance mutex (see acquireInstance), so it
	// can start while this one is still shutting down and simply wait its turn.
	cmd := exec.Command(exe, "--post-update")
	cmd.Dir = dir
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("schedule relaunch: %w", err)
	}
	// Released rather than waited on: this process is about to exit, and a child
	// that outlives its parent must not be left as a zombie handle.
	_ = cmd.Process.Release()
	if a.ctx != nil {
		wruntime.Quit(a.ctx)
	} else {
		os.Exit(0)
	}
	return nil
}

// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
// this process is gone.
//
// The fallback for when the running image cannot be renamed at all. Once OpsLog
// has exited its exe is an ordinary file again, so the move that was refused a
// moment earlier succeeds — and the helper keeps trying for ten seconds, because
// an antivirus that was holding the file usually lets go a beat after the
// process dies rather than instantly.
//
// OpsLog is restarted either way. If the move failed, that starts the OLD build
// — the update simply has not applied — and the operator keeps a working logger
// instead of having it vanish mid-session, which for someone in a QSO is worse
// than an update that waits. Only a successful swap passes --post-update, so a
// failure leaves the .new file in place for the next attempt rather than having
// the cleanup delete the download.
// The LAST resort still needs a helper that outlives this process: nothing else
// can move a file over an image that is still running. It stays PowerShell —
// there is no smaller tool on a stock Windows that can wait for a pid and then
// move a file — but it is reached only when the rename above failed, which is
// rare, and never on the ordinary update path (see the relaunch there for why
// that matters to Defender).
func (a *App) scheduleDeferredSwap(exe, pending string) error {
	// Clear the "downloaded from the internet" mark before it becomes the exe —
	// SmartScreen silently blocks a programmatic launch of a marked file, and the
	// mark follows the file across the move.
	_ = os.Remove(pending + ":Zone.Identifier")

	q := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
	ps := fmt.Sprintf(
		"Wait-Process -Id %d -ErrorAction SilentlyContinue; "+
			"$ok=$false; "+
			"for ($i=0; $i -lt 40; $i++) { "+
			"try { Move-Item -LiteralPath '%s' -Destination '%s' -Force -ErrorAction Stop; $ok=$true; break } "+
			"catch { Start-Sleep -Milliseconds 250 } }; "+
			"if ($ok) { Start-Process -FilePath '%s' -ArgumentList '--post-update' } "+
			"else { Start-Process -FilePath '%s' }",
		os.Getpid(), q(pending), q(exe), q(exe), q(exe))
	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 the update swap: %w", err)
	}
	applog.Printf("update: swap scheduled for after exit (%s → %s)", filepath.Base(pending), filepath.Base(exe))
	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 what a self-update left behind. Called at
// startup after a --post-update relaunch. Best-effort throughout: a file may
// still be locked by a scanner, and the next launch will get it.
//
// Sweeps a PATTERN, not one name. Staging uses a unique ".old-<nanos>" precisely
// so a locked leftover cannot block the next update, which means leftovers
// accumulate unless something collects them — and the pre-0.24.1 ".old" may be
// sitting there too, from the very update that could not delete it.
func cleanupOldUpdateBinary() {
	exe, err := os.Executable()
	if err != nil {
		return
	}
	_ = os.Remove(exe + ".old") // the old fixed name
	_ = os.Remove(exe + ".new") // a deferred swap that has been applied
	matches, err := filepath.Glob(exe + ".old-*")
	if err != nil {
		return
	}
	for _, m := range matches {
		_ = os.Remove(m)
	}
}

// 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
}
