// Package updater implements self-update logic for the investibook CLI.
//
// On startup the CLI calls CheckAndUpdate which:
//  1. Fetches version.json from the investibook-build S3 bucket.
//  2. Compares the remote build_time (Unix timestamp) against the local binary's
//     build_time injected at compile time.  Any newer build triggers a forced update.
//  3. Downloads the platform-matching tar.gz, extracts the new binary, atomically
//     replaces the running executable, and re-execs into it with the original arguments.
//
// The update check is skipped when:
//   - The local BuildTime is "0" or empty (development / untagged builds).
//   - The INVESTIBOOK_SKIP_UPDATE environment variable is set.
//   - The network or S3 bucket is unreachable (errors are silently swallowed).
package updater

import (
	"archive/tar"
	"compress/gzip"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"syscall"
	"time"
)


const (
	// S3BaseURL is the public base URL for the investibook-build bucket.
	S3BaseURL = "https://investibook-build.s3.amazonaws.com"

	// updateTimeout is the maximum time we wait for network I/O during an update.
	updateTimeout = 30 * time.Second

	// checkTimeout is the maximum time we allow for the version check.
	checkTimeout = 5 * time.Second
)

// VersionInfo is the schema of version.json stored in S3.
type VersionInfo struct {
	Version   string `json:"version"`
	BuildTime string `json:"build_time"` // Unix timestamp of the CI build, e.g. "1741680000"
}

// CheckAndUpdate checks S3 for a newer CLI build and, if found, downloads
// and replaces the running binary, then re-execs into it.
//
// localBuildTime is the Unix timestamp string injected at build time via ldflags
// (e.g. "1741680000").  A value of "0" or "" means a dev build — check is skipped.
func CheckAndUpdate(localBuildTime string) {
	if localBuildTime == "" || localBuildTime == "0" {
		return
	}
	if os.Getenv("INVESTIBOOK_SKIP_UPDATE") != "" {
		return
	}

	info, err := fetchVersionInfo()
	if err != nil {
		// Network may be unavailable; never block startup.
		return
	}

	if !isBuildNewer(info.BuildTime, localBuildTime) {
		return
	}

	fmt.Fprintf(os.Stderr, "🔄  New build available (built: %s, current: %s). Auto-updating...\n",
		formatTS(info.BuildTime), formatTS(localBuildTime))

	if err := downloadAndReplace(info.Version); err != nil {
		fmt.Fprintf(os.Stderr, "⚠️  Auto-update failed: %v\n", err)
		fmt.Fprintf(os.Stderr, "    You can update manually: https://github.com/tidbcloud/aiops/releases\n")
		return
	}

	// Re-exec the newly installed binary with the same arguments.
	execPath, err := os.Executable()
	if err != nil {
		fmt.Fprintf(os.Stderr, "⚠️  Updated successfully but could not re-exec: %v\n", err)
		os.Exit(0)
	}

	fmt.Fprintf(os.Stderr, "✅  Updated to %s. Restarting...\n\n", info.Version)
	// syscall.Exec replaces the current process image.
	if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
		fmt.Fprintf(os.Stderr, "⚠️  Re-exec failed: %v\n", err)
		os.Exit(0)
	}
}

// fetchVersionInfo downloads version.json from S3 and returns the parsed VersionInfo.
func fetchVersionInfo() (*VersionInfo, error) {
	ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, S3BaseURL+"/version.json", nil)
	if err != nil {
		return nil, err
	}

	resp, err := http.DefaultClient.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 info VersionInfo
	if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
		return nil, err
	}
	if info.BuildTime == "" {
		return nil, fmt.Errorf("missing build_time in version.json")
	}
	return &info, nil
}

// downloadAndReplace downloads the latest platform binary from the S3 latest/
// path, extracts it from the tar.gz archive, and atomically replaces the running
// executable on disk.  The version parameter is only used for the success message.
func downloadAndReplace(version string) error {
	platform, err := currentPlatform()
	if err != nil {
		return err
	}

	url := fmt.Sprintf("%s/latest/investibook-%s.tar.gz", S3BaseURL, platform)

	ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

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

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, url)
	}

	// Extract the binary from the tar.gz stream.
	newBinary, err := extractBinary(resp.Body)
	if err != nil {
		return fmt.Errorf("extract failed: %w", err)
	}
	defer os.Remove(newBinary)

	// Ensure the new binary is executable.
	if err := os.Chmod(newBinary, 0755); err != nil {
		return err
	}

	// Atomically replace the current executable.
	execPath, err := os.Executable()
	if err != nil {
		return err
	}
	// os.Rename is atomic on the same filesystem (POSIX).
	return os.Rename(newBinary, execPath)
}

// extractBinary reads a .tar.gz stream and writes the first regular file
// (the investibook binary) to a temp file. Returns the temp file path.
func extractBinary(r io.Reader) (string, error) {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return "", err
	}
	defer gz.Close()

	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return "", err
		}
		if hdr.Typeflag != tar.TypeReg {
			continue
		}

		// Write to a temp file next to the eventual destination so that
		// the subsequent os.Rename stays on the same filesystem.
		execPath, err := os.Executable()
		if err != nil {
			return "", err
		}
		dir := filepath.Dir(execPath)
		tmp, err := os.CreateTemp(dir, ".investibook-update-*")
		if err != nil {
			return "", err
		}

		if _, err := io.Copy(tmp, tr); err != nil {
			tmp.Close()
			os.Remove(tmp.Name())
			return "", err
		}
		tmp.Close()
		return tmp.Name(), nil
	}
	return "", fmt.Errorf("no binary found in archive")
}

// currentPlatform returns the os-arch string used in binary file names,
// e.g. "linux-amd64", "darwin-arm64".
func currentPlatform() (string, error) {
	goos := runtime.GOOS
	goarch := runtime.GOARCH

	supported := map[string]bool{
		"linux-amd64":  true,
		"linux-arm64":  true,
		"darwin-arm64": true,
	}

	platform := goos + "-" + goarch
	if !supported[platform] {
		return "", fmt.Errorf("auto-update is not supported on %s (binaries are only published for linux-amd64, linux-arm64, darwin-arm64)", platform)
	}
	return platform, nil
}

// formatTS converts a Unix timestamp string to a human-readable UTC string.
func formatTS(ts string) string {
	n, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || n <= 0 {
		return ts
	}
	return time.Unix(n, 0).UTC().Format("2006-01-02 15:04:05 UTC")
}

// isBuildNewer returns true when the remote build timestamp is strictly greater
// than the local one.  Both are expected to be Unix timestamp strings (decimal).
func isBuildNewer(remote, local string) bool {
	r, err1 := strconv.ParseInt(remote, 10, 64)
	l, err2 := strconv.ParseInt(local, 10, 64)
	if err1 != nil || err2 != nil {
		return false
	}
	return r > l
}
