package updater

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// fakeSource is an in-memory Source implementation for tests.
type fakeSource struct {
	rel *Release
	err error
}

func (f *fakeSource) Latest(ctx context.Context) (*Release, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.rel, nil
}

// ---------------------------------------------------------------------------
// CheckForUpdate
// ---------------------------------------------------------------------------

func TestCheckForUpdate(t *testing.T) {
	t.Parallel()

	rel := func(tag string) *Release {
		return &Release{TagName: tag, Name: tag, AssetURL: "u", SumsURL: "s"}
	}
	sentinel := errors.New("network down")

	tests := []struct {
		name           string
		current        string
		src            Source
		wantHasUpdate  bool
		wantNilRelease bool
		wantErrIs      error
	}{
		{
			name:          "newer release available",
			current:       "0.1.0",
			src:           &fakeSource{rel: rel("v0.2.0")},
			wantHasUpdate: true,
		},
		{
			name:          "newer release with v-prefixed current version",
			current:       "v0.1.0",
			src:           &fakeSource{rel: rel("v0.2.0")},
			wantHasUpdate: true,
		},
		{
			name:           "same version — no update",
			current:        "0.2.0",
			src:            &fakeSource{rel: rel("v0.2.0")},
			wantHasUpdate:  false,
			wantNilRelease: true,
		},
		{
			name:           "older release on remote — no update",
			current:        "0.5.0",
			src:            &fakeSource{rel: rel("v0.2.0")},
			wantHasUpdate:  false,
			wantNilRelease: true,
		},
		{
			name:          "rc < final (semver pre-release ordering)",
			current:       "0.1.0-rc.2",
			src:           &fakeSource{rel: rel("v0.1.0")},
			wantHasUpdate: true,
		},
		{
			name:           "final >= rc",
			current:        "0.1.0",
			src:            &fakeSource{rel: rel("v0.1.0-rc.2")},
			wantHasUpdate:  false,
			wantNilRelease: true,
		},
		{
			name:          "dev current — always update",
			current:       "dev",
			src:           &fakeSource{rel: rel("v0.1.0")},
			wantHasUpdate: true,
		},
		{
			name:          "garbage current — always update (treated as invalid)",
			current:       "not-a-version",
			src:           &fakeSource{rel: rel("v0.1.0")},
			wantHasUpdate: true,
		},
		{
			name:           "source returns nil release — no update",
			current:        "0.1.0",
			src:            &fakeSource{rel: nil},
			wantHasUpdate:  false,
			wantNilRelease: true,
		},
		{
			name:      "source error propagates wrapped",
			current:   "0.1.0",
			src:       &fakeSource{err: sentinel},
			wantErrIs: sentinel,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			ctx := context.Background()
			got, has, err := CheckForUpdate(ctx, tc.src, tc.current)
			if tc.wantErrIs != nil {
				if err == nil {
					t.Fatalf("expected error wrapping %v, got nil", tc.wantErrIs)
				}
				if !errors.Is(err, tc.wantErrIs) {
					t.Fatalf("err = %v; expected to wrap %v", err, tc.wantErrIs)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if has != tc.wantHasUpdate {
				t.Fatalf("hasUpdate = %v; want %v", has, tc.wantHasUpdate)
			}
			if tc.wantNilRelease && got != nil {
				t.Fatalf("expected nil release; got %+v", got)
			}
			if !tc.wantNilRelease && tc.wantHasUpdate && got == nil {
				t.Fatalf("expected non-nil release on update; got nil")
			}
		})
	}
}

// ---------------------------------------------------------------------------
// parseSHA256Sums
// ---------------------------------------------------------------------------

func TestParseSHA256Sums(t *testing.T) {
	t.Parallel()

	const a = "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2"
	const b = "c1e060ee19444a259b2162f8af0f3fe8c4428a1c6f694dce20de194ac8d7d9a2"

	tests := []struct {
		name     string
		input    string
		want     string
		wantName string
		wantErr  bool
	}{
		{
			name:     "standard text-mode line",
			input:    a + "  drover-v0.1.0-windows-amd64.exe\n",
			want:     a,
			wantName: "drover-v0.1.0-windows-amd64.exe",
		},
		{
			name:     "binary-mode asterisk prefix",
			input:    b + " *drover-v0.1.0-setup.exe\n",
			want:     b,
			wantName: "drover-v0.1.0-setup.exe",
		},
		{
			name: "multi-line, target on second",
			input: a + "  some-other-file.txt\n" +
				b + "  drover-v0.1.0-setup.exe\n",
			want:     b,
			wantName: "drover-v0.1.0-setup.exe",
		},
		{
			name: "multi-line, blank lines and CRLF tolerated",
			input: "\r\n" +
				a + "  drover-v0.1.0-windows-amd64.exe\r\n" +
				"\n",
			want:     a,
			wantName: "drover-v0.1.0-windows-amd64.exe",
		},
		{
			name:     "missing entry",
			input:    a + "  some-other-file.txt\n",
			wantName: "drover-v0.1.0-windows-amd64.exe",
			wantErr:  true,
		},
		{
			name:     "malformed line — no separator",
			input:    a + "drover-v0.1.0-windows-amd64.exe\n",
			wantName: "drover-v0.1.0-windows-amd64.exe",
			wantErr:  true,
		},
		{
			name:     "malformed line — bad hex length",
			input:    "deadbeef  drover-v0.1.0-windows-amd64.exe\n",
			wantName: "drover-v0.1.0-windows-amd64.exe",
			wantErr:  true,
		},
		{
			name:     "empty input",
			input:    "",
			wantName: "drover-v0.1.0-windows-amd64.exe",
			wantErr:  true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got, err := parseSHA256Sums([]byte(tc.input), tc.wantName)
			if tc.wantErr {
				if err == nil {
					t.Fatalf("expected error; got hash %q", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tc.want {
				t.Fatalf("hash = %q; want %q", got, tc.want)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// ForgejoSource.Latest
// ---------------------------------------------------------------------------

const sampleReleaseJSON = `{
  "tag_name": "v0.1.0",
  "name": "v0.1.0",
  "draft": false,
  "prerelease": false,
  "created_at": "2026-04-25T13:35:33+03:00",
  "assets": [
    {
      "name": "drover-v0.1.0-windows-amd64.exe",
      "size": 5800000,
      "browser_download_url": "%s/drover-v0.1.0-windows-amd64.exe"
    },
    {
      "name": "SHA256SUMS.txt",
      "size": 200,
      "browser_download_url": "%s/SHA256SUMS.txt"
    }
  ]
}`

func TestForgejoSource_Latest_Success(t *testing.T) {
	t.Parallel()

	var srv *httptest.Server
	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/v1/repos/root/drover-go/releases/latest" {
			t.Errorf("unexpected request path: %s", r.URL.Path)
			http.NotFound(w, r)
			return
		}
		if ua := r.Header.Get("User-Agent"); !strings.HasPrefix(ua, "drover-go/") {
			t.Errorf("expected User-Agent drover-go/...; got %q", ua)
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(w, sampleReleaseJSON, srv.URL, srv.URL)
	}))
	defer srv.Close()

	src := &ForgejoSource{
		baseURL:      srv.URL,
		owner:        "root",
		repo:         "drover-go",
		assetPattern: "windows-amd64.exe",
		client:       srv.Client(),
		userAgent:    "drover-go/test",
	}

	rel, err := src.Latest(context.Background())
	if err != nil {
		t.Fatalf("Latest err = %v", err)
	}
	if rel == nil {
		t.Fatal("nil release returned")
	}
	if rel.TagName != "v0.1.0" {
		t.Errorf("TagName = %q; want v0.1.0", rel.TagName)
	}
	wantAsset := srv.URL + "/drover-v0.1.0-windows-amd64.exe"
	if rel.AssetURL != wantAsset {
		t.Errorf("AssetURL = %q; want %q", rel.AssetURL, wantAsset)
	}
	wantSums := srv.URL + "/SHA256SUMS.txt"
	if rel.SumsURL != wantSums {
		t.Errorf("SumsURL = %q; want %q", rel.SumsURL, wantSums)
	}
	if rel.Prerelease {
		t.Error("Prerelease = true; want false")
	}
	if rel.CreatedAt.IsZero() {
		t.Error("CreatedAt is zero")
	}
}

func TestForgejoSource_Latest_404_NoUpdates(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.NotFound(w, r)
	}))
	defer srv.Close()

	src := &ForgejoSource{
		baseURL:      srv.URL,
		owner:        "root",
		repo:         "drover-go",
		assetPattern: "windows-amd64.exe",
		client:       srv.Client(),
		userAgent:    "drover-go/test",
	}

	rel, err := src.Latest(context.Background())
	if err != nil {
		t.Fatalf("expected nil error on 404; got %v", err)
	}
	if rel != nil {
		t.Fatalf("expected nil release on 404; got %+v", rel)
	}
}

func TestForgejoSource_Latest_500_Error(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "boom", http.StatusInternalServerError)
	}))
	defer srv.Close()

	src := &ForgejoSource{
		baseURL:      srv.URL,
		owner:        "root",
		repo:         "drover-go",
		assetPattern: "windows-amd64.exe",
		client:       srv.Client(),
		userAgent:    "drover-go/test",
	}

	_, err := src.Latest(context.Background())
	if err == nil {
		t.Fatal("expected error on 500; got nil")
	}
	if !strings.Contains(err.Error(), "500") {
		t.Errorf("expected error to mention HTTP 500; got %v", err)
	}
}

func TestForgejoSource_Latest_AssetMissing(t *testing.T) {
	t.Parallel()

	// Sample with only SHA256SUMS, no exe asset.
	body := `{
        "tag_name": "v0.1.0",
        "name": "v0.1.0",
        "draft": false,
        "prerelease": false,
        "created_at": "2026-04-25T13:35:33+03:00",
        "assets": [
            {"name":"SHA256SUMS.txt","size":1,"browser_download_url":"https://example.invalid/s"}
        ]
    }`

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, body)
	}))
	defer srv.Close()

	src := &ForgejoSource{
		baseURL:      srv.URL,
		owner:        "root",
		repo:         "drover-go",
		assetPattern: "windows-amd64.exe",
		client:       srv.Client(),
		userAgent:    "drover-go/test",
	}
	_, err := src.Latest(context.Background())
	if err == nil {
		t.Fatal("expected error when matching asset is missing; got nil")
	}
}

func TestForgejoSource_Latest_SumsMissing(t *testing.T) {
	t.Parallel()

	body := `{
        "tag_name": "v0.1.0",
        "name": "v0.1.0",
        "draft": false,
        "prerelease": false,
        "created_at": "2026-04-25T13:35:33+03:00",
        "assets": [
            {"name":"drover-v0.1.0-windows-amd64.exe","size":1,"browser_download_url":"https://example.invalid/x"}
        ]
    }`

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.WriteString(w, body)
	}))
	defer srv.Close()

	src := &ForgejoSource{
		baseURL:      srv.URL,
		owner:        "root",
		repo:         "drover-go",
		assetPattern: "windows-amd64.exe",
		client:       srv.Client(),
		userAgent:    "drover-go/test",
	}
	_, err := src.Latest(context.Background())
	if err == nil {
		t.Fatal("expected error when SHA256SUMS.txt is missing; got nil")
	}
	if !strings.Contains(err.Error(), "SHA256SUMS.txt") {
		t.Errorf("expected error to mention SHA256SUMS.txt; got %v", err)
	}
}

func TestNewForgejoSource_BuildsURL(t *testing.T) {
	t.Parallel()
	src := NewForgejoSource("git.okcu.io", "root", "drover-go", "windows-amd64.exe").(*ForgejoSource)
	if want := "https://git.okcu.io"; src.baseURL != want {
		t.Errorf("baseURL = %q; want %q", src.baseURL, want)
	}
	if src.owner != "root" || src.repo != "drover-go" {
		t.Errorf("owner/repo wrong: %s/%s", src.owner, src.repo)
	}
	if src.assetPattern != "windows-amd64.exe" {
		t.Errorf("assetPattern = %q", src.assetPattern)
	}
	if src.client == nil {
		t.Error("client must not be nil")
	}
	if !strings.HasPrefix(src.userAgent, "drover-go/") {
		t.Errorf("userAgent = %q; want drover-go/...", src.userAgent)
	}
}

// ---------------------------------------------------------------------------
// downloadAndVerify (the testable extract from ApplyUpdate)
// ---------------------------------------------------------------------------

func TestDownloadAndVerify_Success(t *testing.T) {
	t.Parallel()

	exeData := []byte("MZ\x90fake-windows-binary-content")
	hash := sha256.Sum256(exeData)
	hexHash := hex.EncodeToString(hash[:])

	exeName := "drover-v0.1.0-windows-amd64.exe"
	sumsBody := hexHash + "  " + exeName + "\n"

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/exe":
			w.Header().Set("Content-Length", fmt.Sprintf("%d", len(exeData)))
			_, _ = w.Write(exeData)
		case "/sums":
			_, _ = io.WriteString(w, sumsBody)
		default:
			http.NotFound(w, r)
		}
	}))
	defer srv.Close()

	rel := &Release{
		TagName:  "v0.1.0",
		Name:     "v0.1.0",
		AssetURL: srv.URL + "/exe",
		SumsURL:  srv.URL + "/sums",
	}

	var pcDownloaded, pcTotal int64
	bin, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", func(d, total int64) {
		pcDownloaded = d
		pcTotal = total
	})
	if err != nil {
		t.Fatalf("downloadAndVerify err = %v", err)
	}
	if !equalBytes(bin, exeData) {
		t.Fatalf("downloaded bytes do not match source")
	}
	if pcTotal != int64(len(exeData)) {
		t.Errorf("progress total = %d; want %d", pcTotal, len(exeData))
	}
	if pcDownloaded != int64(len(exeData)) {
		t.Errorf("progress downloaded final = %d; want %d", pcDownloaded, len(exeData))
	}
}

func TestDownloadAndVerify_SHAMismatch(t *testing.T) {
	t.Parallel()

	exeData := []byte("real-binary-bytes")
	exeName := "drover-v0.1.0-windows-amd64.exe"
	wrongHash := strings.Repeat("0", 64)
	sumsBody := wrongHash + "  " + exeName + "\n"

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/exe":
			_, _ = w.Write(exeData)
		case "/sums":
			_, _ = io.WriteString(w, sumsBody)
		default:
			http.NotFound(w, r)
		}
	}))
	defer srv.Close()

	rel := &Release{
		TagName:  "v0.1.0",
		Name:     "v0.1.0",
		AssetURL: srv.URL + "/exe",
		SumsURL:  srv.URL + "/sums",
	}

	_, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", nil)
	if err == nil {
		t.Fatal("expected SHA mismatch error; got nil")
	}
	if !strings.Contains(err.Error(), "sha256") && !strings.Contains(err.Error(), "checksum") {
		t.Errorf("expected error to mention sha256/checksum; got %v", err)
	}
}

func TestDownloadAndVerify_AssetHTTP404(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.NotFound(w, r)
	}))
	defer srv.Close()

	rel := &Release{
		TagName:  "v0.1.0",
		AssetURL: srv.URL + "/exe",
		SumsURL:  srv.URL + "/sums",
	}
	_, err := downloadAndVerify(context.Background(), rel, "drover-v0.1.0-windows-amd64.exe", srv.Client(), "drover-go/test", nil)
	if err == nil {
		t.Fatal("expected error on 404; got nil")
	}
}

func TestDownloadAndVerify_ContextCanceled(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Block forever — handler unblocks when client closes connection
		// (which happens when the test's ctx is canceled).
		select {
		case <-time.After(5 * time.Second):
		case <-r.Context().Done():
		}
	}))
	defer srv.Close()

	rel := &Release{AssetURL: srv.URL + "/exe", SumsURL: srv.URL + "/sums"}
	ctx, cancel := context.WithCancel(context.Background())
	cancel() // cancel immediately

	_, err := downloadAndVerify(ctx, rel, "x", srv.Client(), "drover-go/test", nil)
	if err == nil {
		t.Fatal("expected error from canceled context; got nil")
	}
}

// equalBytes is here to avoid a bytes import for one comparison.
func equalBytes(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// Sanity check that the Forgejo schema fields we care about decode correctly.
func TestReleaseJSONShape(t *testing.T) {
	t.Parallel()
	var rel forgejoRelease
	body := fmt.Sprintf(sampleReleaseJSON, "https://example.invalid", "https://example.invalid")
	if err := json.Unmarshal([]byte(body), &rel); err != nil {
		t.Fatalf("decode err: %v", err)
	}
	if len(rel.Assets) != 2 {
		t.Fatalf("got %d assets; want 2", len(rel.Assets))
	}
}
