package updater

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"

	"github.com/axllent/mailpit/utils/logger"
	"github.com/axllent/semver"
)

var (
	// AllowPrereleases defines whether pre-releases may be included
	AllowPrereleases = false

	tempDir string
)

// Releases struct for Github releases json
type Releases []struct {
	Name       string `json:"name"`       // release name
	Tag        string `json:"tag_name"`   // release tag
	Prerelease bool   `json:"prerelease"` // Github pre-release
	Assets     []struct {
		BrowserDownloadURL string `json:"browser_download_url"`
		ID                 int64  `json:"id"`
		Name               string `json:"name"`
		Size               int64  `json:"size"`
	} `json:"assets"`
}

// Release struct contains the file data for downloadable release
type Release struct {
	Name string
	Tag  string
	URL  string
	Size int64
}

// GithubLatest fetches the latest release info & returns release tag, filename & download url
func GithubLatest(repo, name string) (string, string, string, error) {
	releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)

	resp, err := http.Get(releaseURL) // #nosec
	if err != nil {
		return "", "", "", err
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		return "", "", "", err
	}

	linkOS := runtime.GOOS
	linkArch := runtime.GOARCH
	linkExt := ".tar.gz"
	if linkOS == "windows" {
		// Windows uses .zip instead
		linkExt = ".zip"
	}

	var allReleases = []Release{}

	var releases Releases

	if err := json.Unmarshal(body, &releases); err != nil {
		return "", "", "", err
	}

	archiveName := fmt.Sprintf("%s-%s-%s%s", name, linkOS, linkArch, linkExt)

	// loop through releases
	for _, r := range releases {
		if !semver.IsValid(r.Tag) {
			// Invalid semversion, skip
			continue
		}

		if !AllowPrereleases && (semver.Prerelease(r.Tag) != "" || r.Prerelease) {
			// we don't accept AllowPrereleases, skip
			continue
		}

		for _, a := range r.Assets {
			if a.Name == archiveName {
				thisRelease := Release{a.Name, r.Tag, a.BrowserDownloadURL, a.Size}
				allReleases = append(allReleases, thisRelease)
				break
			}
		}
	}

	if len(allReleases) == 0 {
		// no releases with suitable assets found
		return "", "", "", fmt.Errorf("No binary releases found")
	}

	var latestRelease = Release{}

	for _, r := range allReleases {
		// detect the latest release
		if semver.Compare(r.Tag, latestRelease.Tag) == 1 {
			latestRelease = r
		}
	}

	return latestRelease.Tag, latestRelease.Name, latestRelease.URL, nil
}

// GreaterThan compares the current version to a different version
// returning < 1 not upgradeable
func GreaterThan(toVer, fromVer string) bool {
	return semver.Compare(toVer, fromVer) == 1
}

// GithubUpdate the running binary with the latest release binary from Github
func GithubUpdate(repo, appName, currentVersion string) (string, error) {
	ver, filename, downloadURL, err := GithubLatest(repo, appName)

	if err != nil {
		return "", err
	}

	if ver == currentVersion {
		return "", fmt.Errorf("No new release found")
	}

	if semver.Compare(ver, currentVersion) < 1 {
		return "", fmt.Errorf("No newer releases found (latest %s)", ver)
	}

	tmpDir := getTempDir()

	// outFile can be a tar.gz or a zip, depending on architecture
	outFile := filepath.Join(tmpDir, filename)

	if err := downloadToFile(downloadURL, outFile); err != nil {
		return "", err
	}

	newExec := filepath.Join(tmpDir, "mailpit")

	if runtime.GOOS == "windows" {
		if _, err := Unzip(outFile, tmpDir); err != nil {
			return "", err
		}
		newExec = filepath.Join(tmpDir, "mailpit.exe")
	} else {
		if err := TarGZExtract(outFile, tmpDir); err != nil {
			return "", err
		}
	}

	if runtime.GOOS != "windows" {
		/* #nosec G302 */
		if err := os.Chmod(newExec, 0755); err != nil {
			return "", err
		}
	}

	// ensure the new binary is executable (mainly for inconsistent darwin builds)
	/* #nosec G204 */
	cmd := exec.Command(newExec, "-h")
	if err := cmd.Run(); err != nil {
		return "", err
	}

	// get the running binary
	oldExec, err := os.Executable()
	if err != nil {
		return "", err
	}

	if err = replaceFile(oldExec, newExec); err != nil {
		return "", err
	}

	return ver, nil
}

// DownloadToFile downloads a URL to a file
func downloadToFile(url, fileName string) error {
	// Get the data
	resp, err := http.Get(url) // #nosec
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// Create the file
	out, err := os.Create(filepath.Clean(fileName))
	if err != nil {
		return err
	}

	defer func() {
		if err := out.Close(); err != nil {
			logger.Log().Errorf("Error closing file: %s\n", err)
		}
	}()

	// Write the body to file
	_, err = io.Copy(out, resp.Body)

	return err
}

// ReplaceFile replaces one file with another.
// Running files cannot be overwritten, so it has to be moved
// and the new binary saved to the original path. This requires
// read & write permissions to both the original file and directory.
// Note, on Windows it is not possible to delete a running program,
// so the old exe is renamed and moved to os.TempDir()
func replaceFile(dst, src string) error {
	// open the source file for reading
	source, err := os.Open(filepath.Clean(src))
	if err != nil {
		return err
	}

	// destination directory eg: /usr/local/bin
	dstDir := filepath.Dir(dst)
	// binary filename
	binaryFilename := filepath.Base(dst)
	// old binary tmp name
	dstOld := fmt.Sprintf("%s.old", binaryFilename)
	// new binary tmp name
	dstNew := fmt.Sprintf("%s.new", binaryFilename)
	// absolute path of new tmp file
	newTmpAbs := filepath.Join(dstDir, dstNew)
	// absolute path of old tmp file
	oldTmpAbs := filepath.Join(dstDir, dstOld)

	// get src permissions
	fi, _ := os.Stat(dst)
	srcPerms := fi.Mode().Perm()

	// create the new file
	tmpNew, err := os.OpenFile(filepath.Clean(newTmpAbs), os.O_CREATE|os.O_RDWR, srcPerms) // #nosec
	if err != nil {
		return err
	}

	// copy new binary to <binary>.new
	if _, err := io.Copy(tmpNew, source); err != nil {
		return err
	}

	// close immediately else Windows has a fit
	if err := tmpNew.Close(); err != nil {
		return err
	}

	if err := source.Close(); err != nil {
		return err
	}

	// rename the current executable to <binary>.old
	if err := os.Rename(dst, oldTmpAbs); err != nil {
		return err
	}

	// rename the <binary>.new to current executable
	if err := os.Rename(newTmpAbs, dst); err != nil {
		return err
	}

	// delete the old binary
	if runtime.GOOS == "windows" {
		tmpDir := os.TempDir()
		delFile := filepath.Join(tmpDir, filepath.Base(oldTmpAbs))
		if err := os.Rename(oldTmpAbs, delFile); err != nil {
			return err
		}
	} else {
		if err := os.Remove(oldTmpAbs); err != nil {
			return err
		}
	}

	// remove the src file
	if err := os.Remove(src); err != nil {
		return err
	}

	return nil
}

// GetTempDir will create & return a temporary directory if one has not been specified
func getTempDir() string {
	if tempDir == "" {
		randBytes := make([]byte, 6)
		if _, err := rand.Read(randBytes); err != nil {
			panic(err)
		}
		tempDir = filepath.Join(os.TempDir(), "updater-"+hex.EncodeToString(randBytes))
	}
	if err := mkDirIfNotExists(tempDir); err != nil {
		// need a better way to exit
		logger.Log().Errorf("Error: %v", err)
		os.Exit(2)
	}

	return tempDir
}

// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
	if !isDir(path) {
		return os.MkdirAll(path, os.ModePerm)
	}

	return nil
}

// IsFile returns if a path is a file
func isFile(path string) bool {
	info, err := os.Stat(path)
	if os.IsNotExist(err) || !info.Mode().IsRegular() {
		return false
	}

	return true
}

// IsDir returns if a path is a directory
func isDir(path string) bool {
	info, err := os.Stat(path)
	if os.IsNotExist(err) || !info.IsDir() {
		return false
	}

	return true
}