package main

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

	semver "github.com/Masterminds/semver/v3"
	"github.com/mholt/archiver"
	"github.com/pkg/errors"
)

// Updater updates an app.
type Updater struct {
	// CurrentVersion is the current install version.
	CurrentVersion string
	// LatestReleaseGitHubEndpoint is the URL of the API to get latest release data.
	// For example, https://api.github.com/repos/matryer/xbar/releases/latest.
	LatestReleaseGitHubEndpoint string
	// Client is the HTTP client to use to access the
	// API and download the assets.
	Client *http.Client
	// SelectAsset selects the Asset to install.
	SelectAsset SelectAssetFunc
	// DownloadBytesLimit is the maximum number of bytes to download.
	DownloadBytesLimit int64
	// GetExecutable is the function that gets the current
	// executable. If nil, os.Executable will be used.
	GetExecutable func() (string, error)
}

// SelectAssetFunc selects the Asset to install.
type SelectAssetFunc func(release Release, asset Asset) bool

// Update checks and installs the update.
// Returns nil, nil if no update is required.
func (u *Updater) Update() (*Release, error) {
	if u.DownloadBytesLimit == 0 {
		return nil, errors.New("must set DownloadBytesLimit")
	}
	if u.SelectAsset == nil {
		return nil, errors.New("missing SelectAsset func")
	}
	if u.GetExecutable == nil {
		u.GetExecutable = os.Executable
	}
	latest, err := u.getLatestRelease()
	if err != nil {
		return nil, err
	}
	hasUpdate := hasUpdate(u.CurrentVersion, latest.TagName)
	if !hasUpdate {
		return nil, nil
	}
	var selectedAsset *Asset
	for _, asset := range latest.Assets {
		if u.SelectAsset(*latest, asset) {
			selectedAsset = &asset
			break
		}
	}
	if selectedAsset == nil {
		return nil, errors.New("no asset selected, use SelectAssetFunc to select an asset")
	}
	fmt.Println(selectedAsset)
	err = u.downloadAndReplaceApp(*selectedAsset)
	if err != nil {
		return nil, errors.Wrap(err, "download update")
	}
	return latest, nil
}

// Restart spawns the current executable again, and terminates
// the running one.
func (u *Updater) Restart() error {
	time.Sleep(1 * time.Second)
	thisExecuable, err := os.Executable()
	if err != nil {
		return errors.Wrap(err, "get executable")
	}
	log.Println("restarting", thisExecuable)
	cmd := exec.Command(thisExecuable)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Setpgid: false,
		Credential: &syscall.Credential{
			NoSetGroups: true,
		},
	}
	cmd.Dir = filepath.Dir(thisExecuable)
	cmd.Env = os.Environ()
	cmd.Env = append(cmd.Env, "XBAR_UPDATE_RESTART_COUNTER=1")
	cmd.Args = os.Args
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err = cmd.Start()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			return errors.Wrapf(err, "starting new app failed: exit code %d", exitErr.ExitCode())
		}
		return errors.Wrap(err, "starting new app failed")
	}
	log.Println("waiting before terminating after update...")
	time.Sleep(1 * time.Second)
	log.Println("terminating after update.")
	os.Exit(0)
	return nil
}

// getLatestRelease gets the latest release.
func (u *Updater) getLatestRelease() (*Release, error) {
	resp, err := u.Client.Get(u.LatestReleaseGitHubEndpoint)
	if err != nil {
		return nil, errors.Wrap(err, "get latest release")
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, errors.Errorf("failed to check for updates: got %s", resp.Status)
	}
	b, err := io.ReadAll(io.LimitReader(resp.Body, u.DownloadBytesLimit))
	if err != nil {
		return nil, errors.Wrap(err, "read body")
	}
	var latestReleases []Release
	err = json.Unmarshal(b, &latestReleases)
	if err != nil {
		return nil, errors.Wrap(err, "marshal")
	}
	latestReleases[0].CreatedAt, err = time.Parse(time.RFC3339Nano, latestReleases[0].CreatedAtString)
	if err != nil {
		return nil, errors.Wrap(err, "time.Parse: created_at")
	}
	return &latestReleases[0], nil
}

// HasUpdate checks whether there's an update or not.
func (u *Updater) HasUpdate() (*Release, bool, error) {
	latest, err := u.getLatestRelease()
	fmt.Println(latest)
	if err != nil {
		return nil, false, err
	}
	hasUpdate := hasUpdate(u.CurrentVersion, latest.TagName)
	return latest, hasUpdate, nil
}

// hasUpdate compares the current and latest version strings to
// see if there is an update.
// Returns false if the versions match.
// Returns false is current is in front of latest.
// If semver checking fails, direct string comparison is used.
func hasUpdate(current, latest string) bool {
	semverValid := true
	currentV, err := semver.NewVersion(current)
	if err != nil {
		fmt.Println("CURRENT SEMVER NON VALID")
		semverValid = false
	}
	latestV, err := semver.NewVersion(latest)
	if err != nil {
		fmt.Println("LATEST SEMVER NON VALID")
		semverValid = false
	}
	if semverValid {
		if currentV.Equal(latestV) {
			return false // up-to-date
		}
		if currentV.GreaterThan(latestV) {
			return false // local version is higher
		}
	} else {
		// semver failed - just check tags
		if latest == current {
			return false
		}
	}
	return true
}

func (u *Updater) downloadAndReplaceApp(asset Asset) error {
	filename := path.Base(asset.BrowserDownloadURL)
	switch {
	case strings.HasSuffix(filename, ".zip"):
		// fine
	default:
		return errors.Errorf("file not supported: %s", filename)
	}
	resp, err := u.Client.Get(asset.BrowserDownloadURL)
	if err != nil {
		return errors.Wrap(err, "download asset")
	}
	defer resp.Body.Close()
	fmt.Println(resp.Status)
	const defaultTempDir = ""
	f, err := os.CreateTemp(defaultTempDir, "*-"+filename)
	if err != nil {
		return errors.Wrap(err, "create temp file")
	}
	_, err = io.Copy(f, io.LimitReader(resp.Body, u.DownloadBytesLimit))
	if err != nil {
		f.Close()
		return errors.Wrap(err, "download asset")
	}
	f.Close()
	executable, err := u.GetExecutable()
	if err != nil {
		return errors.Wrap(err, "get executable")
	}
	appPath, err := appPathFromExecutable(executable)
	if err != nil {
		return errors.Wrap(err, "find app path")
	}
	appPathDir := filepath.Dir(appPath)
	appPreviousPath := appPath + ".previous"
	err = os.Rename(appPath, appPreviousPath)
	if err != nil {
		_, statErr := os.Stat(appPath)
		// not exist is ok, just ignore it
		if !os.IsNotExist(statErr) {
			return errors.Wrap(err, "rename existing app")
		}
	}

	fmt.Println("UPDATERRRRR", filename, f.Name(), appPath, appPathDir, appPreviousPath)
	err = archiver.Unarchive(f.Name(), appPathDir)
	if err != nil {
		return errors.Wrap(err, "unarchive")
	}
	err = os.RemoveAll(appPreviousPath)
	if err != nil {
		return errors.Wrap(err, "remove previous")
	}
	return nil
}

// Release is a GitHub release.
type Release struct {
	TagName         string    `json:"tag_name"`
	Assets          []Asset   `json:"assets"`
	Body            string    `json:"body"`
	CreatedAtString string    `json:"created_at"`
	CreatedAt       time.Time `json:"created_at_time"`
}

// Asset is a file within a Release on GitHub.
type Asset struct {
	Name               string `json:"name"`
	BrowserDownloadURL string `json:"browser_download_url"`
}

// appPathFromExecutable gets the .app path from the currently
// running executable.
func appPathFromExecutable(p string) (string, error) {
	if !strings.HasSuffix(p, "/Contents/MacOS/ytd") {
		return "", errors.New("executable not where it should be")
	}
	return strings.TrimSuffix(p, "/Contents/MacOS/ytd"), nil
}