package upgrade

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync/atomic"
	"testing"
	"time"
)

// --- pure helpers ---

func TestIsDev(t *testing.T) {
	t.Parallel()
	cases := []struct {
		in   string
		want bool
	}{
		{"", true},
		{"dev", true},
		{"unknown", true},
		{"(devel)", true},
		{"v0.0.0-20251122000000-abc123", true},
		{"v0.9.0", false},
		{"0.9.0", false},
		{"v1.0.0-rc.1", false},
		// Formatted banner strings — what tui.VersionString() returns.
		{"dev · abc1234", true},
		{"dev · abc1234+", true},
		{"v0.9.0 · abc1234", false},
		{"v0.9.0 · abc1234+", false},
	}
	for _, c := range cases {
		if got := IsDev(c.in); got != c.want {
			t.Errorf("IsDev(%q) = %v, want %v", c.in, got, c.want)
		}
	}
}

func TestCompareSemver(t *testing.T) {
	t.Parallel()
	cases := []struct {
		a, b string
		want int
	}{
		{"v0.9.0", "v0.9.0", 0},
		{"v0.9.0", "v0.10.0", -1},
		{"v0.10.0", "v0.9.0", +1},
		{"v1.0.0", "v0.99.0", +1},
		{"v1.0.0-rc.1", "v1.0.0", -1}, // pre-release < release
		{"v1.0.0", "v1.0.0-rc.1", +1},
		{"v1.0.0-rc.1", "v1.0.0-rc.2", -1},
		{"dev", "v1.0.0", -1}, // dev always < any release
		{"v1.0.0", "dev", +1},
		{"dev", "dev", 0},
		{"0.9.0", "v0.9.0", 0},                       // tolerant of leading v
		{"v0.9.0 · abc1234", "v0.9.1", -1},           // formatted banner string
		{"v1.0.0 · abc1234+", "v1.0.0", 0},           // formatted banner string, equal core
	}
	for _, c := range cases {
		got := compareSemver(c.a, c.b)
		if got != c.want {
			t.Errorf("compareSemver(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
		}
	}
}

func TestUpToDate(t *testing.T) {
	t.Parallel()
	cases := []struct {
		current, tag string
		want         bool
	}{
		{"v0.3.0", "v0.3.1", false},
		{"v0.3.1", "v0.3.1", true},
		{"v0.3.1 · abc1234", "v0.3.1", true},
		{"v0.3.2", "v0.3.1", true},
		{"dev · abc1234", "v0.3.1", false},
		{"", "v0.3.1", false},
		{"v0.3.1", "", true},
	}
	for _, c := range cases {
		if got := UpToDate(c.current, c.tag); got != c.want {
			t.Errorf("UpToDate(%q, %q) = %v, want %v", c.current, c.tag, got, c.want)
		}
	}
}

func TestPickAsset(t *testing.T) {
	t.Parallel()
	assets := []ghAsset{
		{Name: "seek_0.9.0_linux_amd64.tar.gz", BrowserDownloadURL: "u1"},
		{Name: "seek_0.9.0_linux_arm64.tar.gz", BrowserDownloadURL: "u2"},
		{Name: "seek_0.9.0_darwin_amd64.tar.gz", BrowserDownloadURL: "u3"},
		{Name: "seek_0.9.0_darwin_arm64.tar.gz", BrowserDownloadURL: "u4"},
		{Name: "seek_0.9.0_windows_amd64.zip", BrowserDownloadURL: "u5"},
		{Name: "seek_0.9.0_checksums.txt", BrowserDownloadURL: "u6"},
	}
	cases := []struct {
		goos, goarch, want string
	}{
		{"linux", "amd64", "u1"},
		{"linux", "arm64", "u2"},
		{"darwin", "amd64", "u3"},
		{"darwin", "arm64", "u4"},
		{"windows", "amd64", "u5"},
		{"darwin", "aarch64", "u4"}, // alias for arm64
		{"linux", "x86_64", "u1"},   // alias for amd64
	}
	for _, c := range cases {
		got, err := pickAsset(assets, c.goos, c.goarch)
		if err != nil {
			t.Errorf("%s/%s: unexpected err %v", c.goos, c.goarch, err)
			continue
		}
		if got.BrowserDownloadURL != c.want {
			t.Errorf("%s/%s: got %s, want %s", c.goos, c.goarch, got.BrowserDownloadURL, c.want)
		}
	}
}

func TestPickAsset_NoMatch(t *testing.T) {
	t.Parallel()
	if _, err := pickAsset(nil, "plan9", "riscv64"); err == nil {
		t.Error("expected error for empty assets")
	}
	assets := []ghAsset{{Name: "seek_0.9.0_checksums.txt"}}
	if _, err := pickAsset(assets, "linux", "amd64"); err == nil {
		t.Error("expected error when no platform matches")
	}
}

func TestContainsToken(t *testing.T) {
	t.Parallel()
	cases := []struct {
		s, tok string
		want   bool
	}{
		{"seek_0.9.0_linux_amd64.tar.gz", "linux", true},
		{"seek_0.9.0_linux_amd64.tar.gz", "amd64", true},
		{"seek_0.9.0_darwin_arm64.tar.gz", "linux", false},
		{"linuxlike_0.9.0_darwin.tar.gz", "linux", false},     // not bounded
		{"linux_amd64.tar.gz", "linux", true},                 // start
		{"seek_amd64", "amd64", true},                         // end
		{"", "linux", false},
	}
	for _, c := range cases {
		if got := containsToken(c.s, c.tok); got != c.want {
			t.Errorf("containsToken(%q, %q) = %v, want %v", c.s, c.tok, got, c.want)
		}
	}
}

func TestParseChecksums(t *testing.T) {
	t.Parallel()
	// Mix of valid lines, comments, and binary-mode '*'.
	body := strings.NewReader(`# generated by goreleaser
abc123` + strings.Repeat("0", 58) + `  seek_0.9.0_linux_amd64.tar.gz
` + strings.Repeat("a", 64) + `  *seek_0.9.0_windows_amd64.zip

short  invalid_line
`)
	sums, err := parseChecksums(body)
	if err != nil {
		t.Fatalf("parseChecksums err: %v", err)
	}
	if len(sums) != 2 {
		t.Fatalf("got %d entries, want 2: %v", len(sums), sums)
	}
	if _, ok := sums["seek_0.9.0_linux_amd64.tar.gz"]; !ok {
		t.Error("missing linux entry")
	}
	if _, ok := sums["seek_0.9.0_windows_amd64.zip"]; !ok {
		t.Error("missing windows entry (binary-mode '*' should be stripped)")
	}
}

func TestParseChecksums_Empty(t *testing.T) {
	t.Parallel()
	if _, err := parseChecksums(strings.NewReader("# nothing here\n")); err == nil {
		t.Error("expected error for empty checksums file")
	}
}

// --- archive extraction ---

func TestExtractTarGz(t *testing.T) {
	t.Parallel()
	want := []byte("#!/bin/sh\necho hi\n")
	archive := makeTarGz(t, map[string][]byte{
		"README.md": []byte("readme"),
		"seek":      want,
	})

	dir := t.TempDir()
	dst := filepath.Join(dir, "out")
	if err := extractBinary(bytes.NewReader(archive), "seek_0.9.0_linux_amd64.tar.gz", "seek", dst); err != nil {
		t.Fatalf("extractBinary: %v", err)
	}
	got, _ := os.ReadFile(dst)
	if !bytes.Equal(got, want) {
		t.Errorf("extracted bytes mismatch")
	}
	if runtime.GOOS != "windows" {
		info, _ := os.Stat(dst)
		if info.Mode()&0o100 == 0 {
			t.Errorf("extracted file not executable: %v", info.Mode())
		}
	}
}

func TestExtractZip(t *testing.T) {
	t.Parallel()
	want := []byte("MZ\x90\x00fake-exe")
	archive := makeZip(t, map[string][]byte{
		"README.md": []byte("readme"),
		"seek.exe":  want,
	})

	dir := t.TempDir()
	dst := filepath.Join(dir, "out.exe")
	if err := extractBinary(bytes.NewReader(archive), "seek_0.9.0_windows_amd64.zip", "seek.exe", dst); err != nil {
		t.Fatalf("extractBinary: %v", err)
	}
	got, _ := os.ReadFile(dst)
	if !bytes.Equal(got, want) {
		t.Errorf("extracted zip bytes mismatch")
	}
}

func TestExtractBinary_NotFound(t *testing.T) {
	t.Parallel()
	archive := makeTarGz(t, map[string][]byte{"README.md": []byte("readme")})
	dst := filepath.Join(t.TempDir(), "out")
	if err := extractBinary(bytes.NewReader(archive), "a.tar.gz", "seek", dst); err == nil {
		t.Error("expected error when binary missing from archive")
	}
}

// --- full Run() integration via httptest ---

func TestRun_HappyPath(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("new-seek-binary"))
	defer srv.Close()

	exeDir := t.TempDir()
	exePath := filepath.Join(exeDir, "seek")
	if err := os.WriteFile(exePath, []byte("old-seek-binary"), 0o755); err != nil {
		t.Fatal(err)
	}

	var out bytes.Buffer
	res, err := Run(context.Background(), Options{
		Owner:      "whyiyhw",
		Repo:       "seek",
		Current:    "v0.9.0",
		APIBase:    srv.URL,
		HTTPClient: srv.Client(),
		GOOS:       "linux",
		GOARCH:     "amd64",
		ExePath:    exePath,
		Stderr:     &out,
		Stdout:     io.Discard,
	})
	if err != nil {
		t.Fatalf("Run: %v\nstderr:\n%s", err, out.String())
	}
	if res.AlreadyLatest {
		t.Error("expected AlreadyLatest=false")
	}
	if res.To != "v0.9.1" {
		t.Errorf("To = %q, want v0.9.1", res.To)
	}
	got, _ := os.ReadFile(exePath)
	if !bytes.Equal(got, []byte("new-seek-binary")) {
		t.Errorf("binary not replaced: got %q", got)
	}
}

func TestRun_AlreadyLatest(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.0", []byte("anything"))
	defer srv.Close()

	exePath := filepath.Join(t.TempDir(), "seek")
	if err := os.WriteFile(exePath, []byte("original"), 0o755); err != nil {
		t.Fatal(err)
	}
	res, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard,
	})
	if err != ErrAlreadyLatest {
		t.Errorf("err = %v, want ErrAlreadyLatest", err)
	}
	if res == nil || !res.AlreadyLatest {
		t.Errorf("res = %+v", res)
	}
	got, _ := os.ReadFile(exePath)
	if string(got) != "original" {
		t.Errorf("binary changed despite up-to-date: %q", got)
	}
}

func TestRun_ChecksumMismatch(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("real-bytes"))
	// Sabotage the checksums file: serve a wrong sha256.
	flag := byte(1)
	srv.checksumOverride.Store(&flag)
	srv.overrideChecksumBody = "0000000000000000000000000000000000000000000000000000000000000000  seek_0.9.1_linux_amd64.tar.gz\n"

	exePath := filepath.Join(t.TempDir(), "seek")
	_ = os.WriteFile(exePath, []byte("original"), 0o755)

	_, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard,
	})
	if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
		t.Errorf("err = %v, want sha256 mismatch", err)
	}
	got, _ := os.ReadFile(exePath)
	if string(got) != "original" {
		t.Errorf("binary clobbered despite bad checksum: %q", got)
	}
}

func TestRun_NoMatchingAsset(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("real-bytes"))
	defer srv.Close()

	exePath := filepath.Join(t.TempDir(), "seek")
	_ = os.WriteFile(exePath, []byte("original"), 0o755)
	_, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "plan9", GOARCH: "riscv64",
		ExePath: exePath, Stderr: io.Discard,
	})
	if err == nil || !strings.Contains(err.Error(), "no asset matched") {
		t.Errorf("err = %v, want no asset matched", err)
	}
}

func TestRun_DryRun(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("new-bytes"))
	defer srv.Close()

	exePath := filepath.Join(t.TempDir(), "seek")
	_ = os.WriteFile(exePath, []byte("original"), 0o755)
	res, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard,
		DryRun: true,
	})
	if err != nil {
		t.Fatalf("Run: %v", err)
	}
	if !res.DryRun {
		t.Error("expected DryRun=true")
	}
	got, _ := os.ReadFile(exePath)
	if string(got) != "original" {
		t.Errorf("dry-run mutated binary: %q", got)
	}
}

func TestRun_DevBuildRefused(t *testing.T) {
	t.Parallel()
	_, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "dev",
		Stderr:  io.Discard,
	})
	if err == nil || !strings.Contains(err.Error(), "dev") {
		t.Errorf("err = %v, want dev refusal", err)
	}
}

func TestRun_DevBuildAllowed(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("new-bytes"))
	defer srv.Close()

	exePath := filepath.Join(t.TempDir(), "seek")
	_ = os.WriteFile(exePath, []byte("original"), 0o755)
	_, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "dev", AllowDev: true,
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard,
	})
	if err != nil {
		t.Fatalf("Run with AllowDev: %v", err)
	}
}

func TestRun_ContextCancel(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("new-bytes"))
	defer srv.Close()

	ctx, cancel := context.WithCancel(context.Background())
	cancel() // cancel before the call even starts

	exePath := filepath.Join(t.TempDir(), "seek")
	_ = os.WriteFile(exePath, []byte("original"), 0o755)
	_, err := Run(ctx, Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard,
	})
	if err == nil {
		t.Error("expected error when ctx is canceled")
	}
}

func TestCheck_AlreadyLatest(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v0.9.0", []byte("anything"))
	defer srv.Close()
	rel, err := Check(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
	})
	if err != nil {
		t.Fatalf("Check: %v", err)
	}
	if rel != nil {
		t.Errorf("Check returned %v, want nil for up-to-date", rel.TagName)
	}
}

func TestCheck_NewerAvailable(t *testing.T) {
	t.Parallel()
	srv := newFakeReleaseServer(t, "v1.0.0", []byte("anything"))
	defer srv.Close()
	rel, err := Check(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
	})
	if err != nil {
		t.Fatalf("Check: %v", err)
	}
	if rel == nil || rel.TagName != "v1.0.0" {
		t.Errorf("Check got %v, want v1.0.0", rel)
	}
}

func TestGitHubTokenFromEnv(t *testing.T) {
	t.Setenv("GITHUB_TOKEN", "")
	t.Setenv("GH_TOKEN", "")
	if got := githubTokenFromEnv(); got != "" {
		t.Errorf("githubTokenFromEnv() = %q, want empty", got)
	}
	t.Setenv("GH_TOKEN", "gh-token")
	if got := githubTokenFromEnv(); got != "gh-token" {
		t.Errorf("githubTokenFromEnv() = %q, want gh-token", got)
	}
	t.Setenv("GITHUB_TOKEN", "github-token")
	if got := githubTokenFromEnv(); got != "github-token" {
		t.Errorf("githubTokenFromEnv() = %q, want github-token", got)
	}
}

func TestWithDefaults_ReadsTokenFromEnv(t *testing.T) {
	t.Setenv("GITHUB_TOKEN", "from-env")
	opt := withDefaults(Options{})
	if opt.Token != "from-env" {
		t.Errorf("Token = %q, want from-env", opt.Token)
	}
}

func TestCheck_SendsBearerToken(t *testing.T) {
	t.Parallel()
	const wantToken = "ghp_test_token"
	var gotAuth string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		if r.URL.Path != "/repos/whyiyhw/seek/releases/latest" {
			http.NotFound(w, r)
			return
		}
		_ = json.NewEncoder(w).Encode(ghRelease{TagName: "v0.9.0"})
	}))
	t.Cleanup(srv.Close)

	_, err := Check(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		Token:   wantToken,
		APIBase: srv.URL, HTTPClient: srv.Client(),
	})
	if err != nil {
		t.Fatalf("Check: %v", err)
	}
	if gotAuth != "Bearer "+wantToken {
		t.Errorf("Authorization = %q, want Bearer %q", gotAuth, wantToken)
	}
}

func TestRun_SendsBearerTokenOnDownload(t *testing.T) {
	t.Parallel()
	const wantToken = "ghp_test_token"
	srv := newFakeReleaseServer(t, "v0.9.1", []byte("new-seek-binary"))

	exePath := filepath.Join(t.TempDir(), "seek")
	if err := os.WriteFile(exePath, []byte("old-seek-binary"), 0o755); err != nil {
		t.Fatal(err)
	}

	_, err := Run(context.Background(), Options{
		Owner: "whyiyhw", Repo: "seek",
		Current: "v0.9.0",
		Token:   wantToken,
		APIBase: srv.URL, HTTPClient: srv.Client(),
		GOOS: "linux", GOARCH: "amd64",
		ExePath: exePath, Stderr: io.Discard, Stdout: io.Discard,
	})
	if err != nil {
		t.Fatalf("Run: %v", err)
	}
	if got := srv.lastDownloadAuth.Load(); got != "Bearer "+wantToken {
		t.Errorf("download Authorization = %v, want Bearer %q", got, wantToken)
	}
}

// --- fake GitHub server ---

// fakeReleaseServer simulates the subset of github.com endpoints the
// upgrader hits: /repos/.../releases/latest and the asset download URLs.
// All paths are served from the same httptest.Server; the asset URLs
// are derived from srv.URL so the upgrader's host resolution lines up.
type fakeReleaseServer struct {
	*httptest.Server
	tag              string
	archive          []byte // tar.gz containing "seek"
	checksumsBody    string
	overrideChecksumBody string
	checksumOverride atomic.Pointer[byte] // if non-nil, serve overrideChecksumBody
	reqCount         atomic.Int64
	lastDownloadAuth atomic.Value // string — Authorization on last /dl/ request
}

func newFakeReleaseServer(t *testing.T, tag string, binaryBytes []byte) *fakeReleaseServer {
	t.Helper()
	mux := http.NewServeMux()
	srv := &fakeReleaseServer{tag: tag}

	srv.archive = makeTarGz(t, map[string][]byte{"seek": binaryBytes})
	archiveName := fmt.Sprintf("seek_%s_linux_amd64.tar.gz", strings.TrimPrefix(tag, "v"))
	srv.checksumsBody = fmt.Sprintf("%s  %s\n", sha256Hex(srv.archive), archiveName)

	mux.HandleFunc("/repos/whyiyhw/seek/releases/latest", func(w http.ResponseWriter, r *http.Request) {
		srv.reqCount.Add(1)
		base := "http://" + r.Host
		body := ghRelease{
			TagName:     tag,
			Name:        "seek " + tag,
			PublishedAt: time.Now().UTC(),
			Assets: []ghAsset{
				{Name: archiveName, BrowserDownloadURL: base + "/dl/" + archiveName, Size: int64(len(srv.archive))},
				{Name: "seek_" + strings.TrimPrefix(tag, "v") + "_checksums.txt", BrowserDownloadURL: base + "/dl/checksums.txt", Size: int64(len(srv.checksumsBody))},
			},
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(body)
	})

	mux.HandleFunc("/dl/", func(w http.ResponseWriter, r *http.Request) {
		srv.lastDownloadAuth.Store(r.Header.Get("Authorization"))
		switch {
		case strings.HasSuffix(r.URL.Path, "checksums.txt"):
			if srv.checksumOverride.Load() != nil {
				_, _ = w.Write([]byte(srv.overrideChecksumBody))
				return
			}
			_, _ = w.Write([]byte(srv.checksumsBody))
		case strings.HasSuffix(r.URL.Path, ".tar.gz"):
			_, _ = w.Write(srv.archive)
		default:
			http.NotFound(w, r)
		}
	})

	srv.Server = httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

// --- archive constructors used by tests ---

func makeTarGz(t *testing.T, files map[string][]byte) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for name, content := range files {
		hdr := &tar.Header{
			Name:    name,
			Mode:    0o755,
			Size:    int64(len(content)),
			ModTime: time.Now(),
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatal(err)
		}
		if _, err := tw.Write(content); err != nil {
			t.Fatal(err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gz.Close(); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

func makeZip(t *testing.T, files map[string][]byte) []byte {
	t.Helper()
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for name, content := range files {
		f, err := zw.Create(name)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := f.Write(content); err != nil {
			t.Fatal(err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}
