package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"time"

	"nadir"
	"nadir/internal/config"

	"github.com/jedisct1/go-minisign"
)

// updateCmd implements `nadir update`: hit the configured Gitea repo's
// releases/latest, pick the asset for the host's GOARCH, verify the release
// signature (minisign) and checksum (SHA-256), atomically replace
// /usr/local/bin/nadir (or wherever the running binary lives), and restart
// the systemd unit so the new code takes effect.
func updateCmd(args []string) error {
	fs := flag.NewFlagSet("update", flag.ExitOnError)
	check := fs.Bool("check", false, "report the latest version without downloading")
	force := fs.Bool("force", false, "re-download even when already at the latest version")
	fs.Parse(args)

	configPath, err := resolveConfigPath()
	if err != nil {
		return err
	}
	cfg, err := config.Load(configPath)
	if err != nil {
		return err
	}
	if cfg.Server.ReleaseRepo == "" {
		return fmt.Errorf("server.release_repo not set in %s", configPath)
	}

	apiURL, err := releaseAPIURL(cfg.Server.ReleaseRepo)
	if err != nil {
		return err
	}

	suffix := "linux-" + runtime.GOARCH
	fmt.Printf("querying %s ...\n", apiURL)
	rel, err := fetchLatestRelease(apiURL)
	if err != nil {
		return err
	}

	fmt.Printf("current: %s\nlatest:  %s\n", nadir.Version, rel.TagName)
	upToDate := nadir.Version == rel.TagName
	switch {
	case *check:
		if upToDate {
			fmt.Println("already up to date.")
		} else {
			fmt.Println("update available; run `nadir update` to install.")
		}
		return nil
	case upToDate && !*force:
		fmt.Println("already up to date; pass --force to re-download.")
		return nil
	}

	// Locate the binary asset and the two verification assets in the release.
	var assetURL, assetName string
	var sumsURL, sigURL string
	for _, a := range rel.Assets {
		switch {
		case strings.HasSuffix(a.Name, suffix):
			assetURL, assetName = a.URL, a.Name
		case a.Name == "sha256sums.txt":
			sumsURL = a.URL
		case a.Name == "sha256sums.txt.minisig":
			sigURL = a.URL
		}
	}
	if assetURL == "" {
		return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName)
	}
	if sumsURL == "" || sigURL == "" {
		return fmt.Errorf("release %s is missing sha256sums.txt or sha256sums.txt.minisig — cannot verify; refusing to install an unverified binary", rel.TagName)
	}

	exe, err := os.Executable()
	if err != nil {
		return err
	}

	fmt.Printf("downloading %s (%s) ...\n", assetName, rel.TagName)
	tmp := exe + ".tmp"
	if err := download(assetURL, tmp); err != nil {
		os.Remove(tmp)
		return err
	}

	// Verify: download checksums + signature, check minisign, check SHA-256.
	fmt.Println("verifying release signature ...")
	if err := verifyRelease(tmp, assetName, sumsURL, sigURL); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("verification failed: %w", err)
	}
	fmt.Println("signature and checksum OK.")

	// Atomic on the same filesystem; replaces the on-disk file without
	// disturbing the still-running process (its inode stays alive).
	if err := os.Rename(tmp, exe); err != nil {
		os.Remove(tmp)
		return err
	}
	fmt.Printf("installed %s at %s\n", rel.TagName, exe)

	fmt.Println("restarting service ...")
	if err := exec.Command("systemctl", "restart", serviceName).Run(); err != nil {
		fmt.Fprintf(os.Stderr, "warning: could not restart %s service (%v); restart it manually to pick up the new binary\n", serviceName, err)
	}
	return nil
}

// verifyRelease downloads sha256sums.txt + its minisig from the release,
// verifies the signature against the embedded public key, then checks the
// downloaded binary's SHA-256 against the matching line in the checksums file.
func verifyRelease(binaryPath, assetName, sumsURL, sigURL string) error {
	// Parse the embedded public key. The placeholder file will fail here
	// (no valid base64 line), which is the desired behavior: updates are
	// disabled until a real key is committed.
	pubKey, err := minisign.DecodePublicKey(nadir.ReleasePublicKey)
	if err != nil {
		return fmt.Errorf("embedded minisign public key is invalid (is minisign.pub still the placeholder?): %w", err)
	}

	// Download the checksums file and its signature.
	sumsBody, err := downloadBytes(sumsURL)
	if err != nil {
		return fmt.Errorf("download sha256sums.txt: %w", err)
	}
	sigBody, err := downloadBytes(sigURL)
	if err != nil {
		return fmt.Errorf("download sha256sums.txt.minisig: %w", err)
	}

	// Verify the minisign signature on the checksums file.
	sig, err := minisign.DecodeSignature(string(sigBody))
	if err != nil {
		return fmt.Errorf("decode minisig: %w", err)
	}
	ok, err := pubKey.Verify(sumsBody, sig)
	if err != nil {
		return fmt.Errorf("minisign verify: %w", err)
	}
	if !ok {
		return fmt.Errorf("minisign signature is not valid for this sha256sums.txt")
	}

	// Find the expected hash for our binary in the signed checksums file.
	expectedHash, err := findChecksum(string(sumsBody), assetName)
	if err != nil {
		return err
	}

	// Hash the downloaded binary and compare.
	actualHash, err := sha256File(binaryPath)
	if err != nil {
		return fmt.Errorf("hash downloaded binary: %w", err)
	}
	if actualHash != expectedHash {
		return fmt.Errorf("SHA-256 mismatch for %s: expected %s, got %s", assetName, expectedHash, actualHash)
	}
	return nil
}

// findChecksum parses a sha256sums.txt body (one "hash  filename\n" per line)
// and returns the hex hash for the named asset.
func findChecksum(sums, assetName string) (string, error) {
	for _, line := range strings.Split(sums, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		// Standard sha256sum output: "<hex>  <filename>" (two spaces).
		parts := strings.SplitN(line, "  ", 2)
		if len(parts) != 2 {
			// Also accept single-space separation.
			parts = strings.SplitN(line, " ", 2)
		}
		if len(parts) == 2 && strings.TrimSpace(parts[1]) == assetName {
			h := strings.TrimSpace(parts[0])
			if len(h) != 64 {
				return "", fmt.Errorf("sha256sums.txt: invalid hash length for %s: %q", assetName, h)
			}
			return strings.ToLower(h), nil
		}
	}
	return "", fmt.Errorf("sha256sums.txt does not contain a hash for %s", assetName)
}

// sha256File returns the lowercase hex SHA-256 of the file at path.
func sha256File(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// downloadBytes fetches a URL and returns its body as a byte slice (for small
// files like sha256sums.txt and .minisig).
func downloadBytes(srcURL string) ([]byte, error) {
	c := &http.Client{Timeout: 30 * time.Second}
	resp, err := c.Get(srcURL)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("GET %s: %s", srcURL, resp.Status)
	}
	// Cap read to 1 MB — sha256sums.txt and .minisig are tiny.
	return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}

type giteaRelease struct {
	TagName string `json:"tag_name"`
	Assets  []struct {
		Name string `json:"name"`
		URL  string `json:"browser_download_url"`
	} `json:"assets"`
}

func fetchLatestRelease(apiURL string) (*giteaRelease, error) {
	c := &http.Client{Timeout: 30 * time.Second}
	resp, err := c.Get(apiURL)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("releases API returned %s", resp.Status)
	}
	var r giteaRelease
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return nil, fmt.Errorf("decode release JSON: %w", err)
	}
	return &r, nil
}

// releaseAPIURL converts https://host/owner/repo (the same URL pasted into
// release_repo and shown in a browser) into the Gitea releases/latest API
// endpoint: https://host/api/v1/repos/owner/repo/releases/latest.
func releaseAPIURL(repoURL string) (string, error) {
	u, err := url.Parse(repoURL)
	if err != nil {
		return "", fmt.Errorf("release_repo: %w", err)
	}
	if u.Scheme != "https" {
		return "", fmt.Errorf("release_repo must use https:// (got %q) — auto-update downloads and executes the binary, plaintext would let any on-path attacker replace it", repoURL)
	}
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
		return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL)
	}
	return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s/releases/latest", u.Scheme, u.Host, parts[0], parts[1]), nil
}

func download(srcURL, dst string) error {
	c := &http.Client{Timeout: 5 * time.Minute}
	resp, err := c.Get(srcURL)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download %s: %s", srcURL, resp.Status)
	}
	f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
	if err != nil {
		return err
	}
	defer f.Close()
	if _, err := io.Copy(f, resp.Body); err != nil {
		return err
	}
	return nil
}
