package update

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"context"
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestCheckForUpdate_Old(t *testing.T) {
	info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"})
	require.NoError(t, err)
	require.NotNil(t, info)
	require.True(t, info.Available())
}

func TestCheckForUpdate_Beta(t *testing.T) {
	t.Run("current is stable", func(t *testing.T) {
		info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
		require.NoError(t, err)
		require.NotNil(t, info)
		require.False(t, info.Available())
	})

	t.Run("current is also beta", func(t *testing.T) {
		info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"})
		require.NoError(t, err)
		require.NotNil(t, info)
		require.True(t, info.Available())
	})

	t.Run("current is beta, latest isn't", func(t *testing.T) {
		info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"})
		require.NoError(t, err)
		require.NotNil(t, info)
		require.True(t, info.Available())
	})
}

func TestCheckForUpdate_NetworkError(t *testing.T) {
	t.Parallel()
	_, err := Check(t.Context(), "0.19.0", errorClient{errors.New("network unreachable")})
	require.Error(t, err)
	require.Contains(t, err.Error(), "failed to fetch")
}

type testClient struct{ tag string }

// Latest implements Client.
func (t testClient) Latest(ctx context.Context) (*Release, error) {
	return &Release{
		TagName: t.tag,
		HTMLURL: "https://example.org",
	}, nil
}

type errorClient struct{ err error }

// Latest implements Client.
func (e errorClient) Latest(ctx context.Context) (*Release, error) {
	return nil, e.err
}

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

	// Create test assets matching goreleaser naming.
	assets := []Asset{
		{Name: "crush_0.19.2_Linux_x86_64.tar.gz", BrowserDownloadURL: "https://example.com/linux-amd64.tar.gz"},
		{Name: "crush_0.19.2_Darwin_x86_64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-amd64.tar.gz"},
		{Name: "crush_0.19.2_Darwin_arm64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-arm64.tar.gz"},
		{Name: "crush_0.19.2_Windows_x86_64.zip", BrowserDownloadURL: "https://example.com/windows-amd64.zip"},
		{Name: "crush_0.19.2_Linux_i386.tar.gz", BrowserDownloadURL: "https://example.com/linux-386.tar.gz"},
		{Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
		{Name: "crush_0.19.2_Linux_x86_64.tar.gz.sig", BrowserDownloadURL: "https://example.com/linux-amd64.tar.gz.sig"},
	}

	t.Run("finds correct asset for current platform", func(t *testing.T) {
		t.Parallel()
		asset, err := FindAsset(assets)
		require.NoError(t, err)
		require.NotNil(t, asset)

		// Check that the asset matches our platform.
		switch runtime.GOOS {
		case "linux":
			require.Contains(t, asset.Name, "Linux")
		case "darwin":
			require.Contains(t, asset.Name, "Darwin")
		case "windows":
			require.Contains(t, asset.Name, "Windows")
		}

		// Check that it's an archive, not a signature or checksum.
		require.True(t, strings.HasSuffix(asset.Name, ".tar.gz") || strings.HasSuffix(asset.Name, ".zip"))
	})

	t.Run("returns error when no matching asset", func(t *testing.T) {
		t.Parallel()
		emptyAssets := []Asset{
			{Name: "checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
		}
		asset, err := FindAsset(emptyAssets)
		require.Error(t, err)
		require.Nil(t, asset)
	})
}

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

	tests := []struct {
		name    string
		version string
		want    bool
	}{
		{"devel version", "devel", true},
		{"unknown version", "unknown", true},
		{"dirty version", "0.19.0-dirty", true},
		{"dirty with suffix", "0.19.0-10-g1234567-dirty", true},
		{"go install pseudo-version", "v0.0.0-0.20251231235959-06c807842604", true},
		{"git describe version", "v0.19.0-15-g1a2b3c4d", true},
		{"git describe short hash", "0.19.0-3-gabcdef0", true},
		{"git describe long hash", "v1.0.0-100-g0123456789ab", true},
		{"stable version", "0.19.0", false},
		{"pre-release beta", "0.19.0-beta.1", false},
		{"pre-release rc", "0.19.0-rc.1", false},
		{"pre-release alpha", "0.19.0-alpha.1", false},
		{"major version", "1.0.0", false},
		{"with v prefix", "v2.0.0", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			info := Info{Current: tt.version, Latest: "0.20.0"}
			require.Equal(t, tt.want, info.IsDevelopment())
		})
	}
}

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

	tests := []struct {
		name    string
		current string
		latest  string
		want    bool
	}{
		// Basic cases.
		{"same version", "0.19.0", "0.19.0", false},
		{"newer available", "0.19.0", "0.19.1", true},
		{"older latest (no downgrade)", "0.19.1", "0.19.0", false},

		// Pre-release handling.
		{"rc to stable", "0.19.0-rc.1", "0.19.0", true},
		{"stable to rc", "0.19.0", "0.20.0-rc.1", false},
		{"alpha to beta", "0.19.0-alpha.1", "0.19.0-beta.1", true},
		{"beta to rc", "0.19.0-beta.1", "0.19.0-rc.1", true},
		{"same pre-release", "0.19.0-beta.1", "0.19.0-beta.1", false},

		// Semver edge cases - multi-digit versions.
		{"0.9.9 to 0.10.0", "0.9.9", "0.10.0", true},
		{"0.19.0 to 0.19.10", "0.19.0", "0.19.10", true},
		{"1.9.0 to 1.10.0", "1.9.0", "1.10.0", true},

		// Major version bumps.
		{"0.x to 1.0", "0.99.99", "1.0.0", true},
		{"1.x to 2.0", "1.0.0", "2.0.0", true},

		// With v prefix.
		{"v prefix current", "v0.19.0", "0.19.1", true},
		{"v prefix latest", "0.19.0", "v0.19.1", true},
		{"v prefix both", "v0.19.0", "v0.19.1", true},

		// Malformed versions should return false.
		{"malformed current", "not-a-version", "0.19.0", false},
		{"malformed latest", "0.19.0", "not-a-version", false},
		{"both malformed", "bad", "worse", false},

		// Build metadata is ignored in semver comparison.
		{"build metadata only diff", "1.0.0+build.1", "1.0.0+build.2", false},
		{"build metadata vs plain", "1.0.0", "1.0.0+build.1", false},
		{"build metadata with newer version", "1.0.0+build.1", "1.0.1", true},

		// Pre-release ordering edge cases.
		{"prerelease alpha ordering", "1.0.0-alpha", "1.0.0-alpha.1", true},
		{"prerelease numeric ordering", "1.0.0-1", "1.0.0-2", true},
		{"prerelease alpha vs beta", "1.0.0-alpha.2", "1.0.0-beta.1", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			info := Info{Current: tt.current, Latest: tt.latest}
			require.Equal(t, tt.want, info.Available())
		})
	}
}

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

	// Valid SHA256 hashes for testing (64 hex characters).
	hash1 := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
	hash2 := "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"

	tests := []struct {
		name  string
		input string
		want  map[string]string
	}{
		{
			name:  "standard format",
			input: hash1 + "  crush_0.19.2_Linux_x86_64.tar.gz\n" + hash2 + "  crush_0.19.2_Darwin_arm64.tar.gz\n",
			want: map[string]string{
				"crush_0.19.2_Linux_x86_64.tar.gz": hash1,
				"crush_0.19.2_Darwin_arm64.tar.gz": hash2,
			},
		},
		{
			name:  "empty lines",
			input: hash1 + "  file1.tar.gz\n\n" + hash2 + "  file2.tar.gz\n",
			want: map[string]string{
				"file1.tar.gz": hash1,
				"file2.tar.gz": hash2,
			},
		},
		{
			name:  "extra fields ignored",
			input: hash1 + "  file1.tar.gz  extra  fields\n",
			want:  map[string]string{},
		},
		{
			name:  "single field ignored",
			input: hash1 + "\n",
			want:  map[string]string{},
		},
		{
			name:  "whitespace variations",
			input: hash1 + "\tfile1.tar.gz\n",
			want: map[string]string{
				"file1.tar.gz": hash1,
			},
		},
		{
			name:  "invalid checksum length ignored",
			input: "abc123  file1.tar.gz\n",
			want:  map[string]string{},
		},
		{
			name:  "invalid checksum hex ignored",
			input: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz  file1.tar.gz\n",
			want:  map[string]string{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			result := parseChecksumLines(tt.input)
			require.Equal(t, tt.want, result)
		})
	}
}

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

	t.Run("missing binary", func(t *testing.T) {
		t.Parallel()
		// Create a tar.gz with no crush binary.
		tmpDir := t.TempDir()
		archivePath := filepath.Join(tmpDir, "test.tar.gz")

		f, err := os.Create(archivePath)
		require.NoError(t, err)

		gzw := gzip.NewWriter(f)
		tw := tar.NewWriter(gzw)

		// Add a random file, not crush.
		content := []byte("not a binary")
		hdr := &tar.Header{
			Name: "other-file.txt",
			Mode: 0o644,
			Size: int64(len(content)),
		}
		require.NoError(t, tw.WriteHeader(hdr))
		_, err = tw.Write(content)
		require.NoError(t, err)

		require.NoError(t, tw.Close())
		require.NoError(t, gzw.Close())
		require.NoError(t, f.Close())

		_, err = extractTarGz(archivePath)
		require.Error(t, err)
		require.Contains(t, err.Error(), "not found")
	})

	t.Run("path traversal attempt", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		archivePath := filepath.Join(tmpDir, "test.tar.gz")

		f, err := os.Create(archivePath)
		require.NoError(t, err)

		gzw := gzip.NewWriter(f)
		tw := tar.NewWriter(gzw)

		// Add a file with path traversal attempt.
		content := []byte("malicious")
		hdr := &tar.Header{
			Name: "../../../etc/passwd",
			Mode: 0o644,
			Size: int64(len(content)),
		}
		require.NoError(t, tw.WriteHeader(hdr))
		_, err = tw.Write(content)
		require.NoError(t, err)

		require.NoError(t, tw.Close())
		require.NoError(t, gzw.Close())
		require.NoError(t, f.Close())

		// Should not extract the malicious file and should fail to find binary.
		_, err = extractTarGz(archivePath)
		require.Error(t, err)
		require.Contains(t, err.Error(), "not found")
	})
}

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

	t.Run("missing binary", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		archivePath := filepath.Join(tmpDir, "test.zip")

		f, err := os.Create(archivePath)
		require.NoError(t, err)

		zw := zip.NewWriter(f)

		// Add a random file, not crush.
		w, err := zw.Create("other-file.txt")
		require.NoError(t, err)
		_, err = w.Write([]byte("not a binary"))
		require.NoError(t, err)

		require.NoError(t, zw.Close())
		require.NoError(t, f.Close())

		_, err = extractZip(archivePath)
		require.Error(t, err)
		require.Contains(t, err.Error(), "not found")
	})

	t.Run("path traversal attempt", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		archivePath := filepath.Join(tmpDir, "test.zip")

		f, err := os.Create(archivePath)
		require.NoError(t, err)

		zw := zip.NewWriter(f)

		// Add a file with path traversal attempt.
		w, err := zw.Create("../../../etc/passwd")
		require.NoError(t, err)
		_, err = w.Write([]byte("malicious"))
		require.NoError(t, err)

		require.NoError(t, zw.Close())
		require.NoError(t, f.Close())

		// Should not extract the malicious file and should fail to find binary.
		_, err = extractZip(archivePath)
		require.Error(t, err)
		require.Contains(t, err.Error(), "not found")
	})
}

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

	t.Run("read-only directory", func(t *testing.T) {
		t.Parallel()
		if runtime.GOOS == "windows" {
			t.Skip("chmod not reliable on Windows")
		}

		tmpDir := t.TempDir()

		// Create a fake binary to apply.
		binaryPath := filepath.Join(tmpDir, "new-binary")
		require.NoError(t, os.WriteFile(binaryPath, []byte("new"), 0o755))

		// Create a read-only directory.
		readOnlyDir := filepath.Join(tmpDir, "readonly")
		require.NoError(t, os.MkdirAll(readOnlyDir, 0o755))

		// Create a fake executable in the read-only dir.
		exePath := filepath.Join(readOnlyDir, "crush")
		require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))

		// Make the directory read-only.
		require.NoError(t, os.Chmod(readOnlyDir, 0o555))
		t.Cleanup(func() {
			// Restore permissions for cleanup.
			os.Chmod(readOnlyDir, 0o755)
		})

		// checkWritePermission should fail.
		err := checkWritePermission(readOnlyDir)
		require.Error(t, err)
	})

	t.Run("successful copy", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()

		src := filepath.Join(tmpDir, "src")
		dst := filepath.Join(tmpDir, "dst")

		content := []byte("test content")
		require.NoError(t, os.WriteFile(src, content, 0o755))

		require.NoError(t, copyFile(src, dst))

		dstContent, err := os.ReadFile(dst)
		require.NoError(t, err)
		require.Equal(t, content, dstContent)
	})
}

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

	tests := []struct {
		method InstallMethod
		want   string
	}{
		{InstallMethodUnknown, "unknown"},
		{InstallMethodBinary, "binary"},
		{InstallMethodHomebrew, "Homebrew"},
		{InstallMethodNPM, "npm"},
		{InstallMethodAUR, "AUR"},
		{InstallMethodNix, "Nix"},
		{InstallMethodWinget, "winget"},
		{InstallMethodScoop, "Scoop"},
		{InstallMethodApt, "apt"},
		{InstallMethodYum, "yum"},
		{InstallMethodGoInstall, "go install"},
	}

	for _, tt := range tests {
		t.Run(tt.want, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, tt.method.String())
		})
	}
}

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

	tests := []struct {
		method InstallMethod
		want   bool
	}{
		{InstallMethodUnknown, true},
		{InstallMethodBinary, true},
		{InstallMethodHomebrew, false},
		{InstallMethodNPM, false},
		{InstallMethodAUR, false},
		{InstallMethodNix, false},
		{InstallMethodWinget, false},
		{InstallMethodScoop, false},
		{InstallMethodApt, false},
		{InstallMethodYum, false},
		{InstallMethodGoInstall, false},
	}

	for _, tt := range tests {
		t.Run(tt.method.String(), func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, tt.method.CanSelfUpdate())
		})
	}
}

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

	tests := []struct {
		method   InstallMethod
		contains string
	}{
		{InstallMethodHomebrew, "brew upgrade"},
		{InstallMethodNPM, "npm update"},
		{InstallMethodAUR, "yay -Syu crush-bin"},
		{InstallMethodNix, "nix"},
		{InstallMethodWinget, "winget upgrade"},
		{InstallMethodScoop, "scoop update"},
		{InstallMethodApt, "apt"},
		{InstallMethodYum, "yum update"},
		{InstallMethodGoInstall, "go install"},
	}

	for _, tt := range tests {
		t.Run(tt.method.String(), func(t *testing.T) {
			t.Parallel()
			instructions := tt.method.UpdateInstructions()
			require.Contains(t, instructions, tt.contains)
		})
	}

	t.Run("unknown returns empty", func(t *testing.T) {
		t.Parallel()
		require.Empty(t, InstallMethodUnknown.UpdateInstructions())
	})

	t.Run("binary returns empty", func(t *testing.T) {
		t.Parallel()
		require.Empty(t, InstallMethodBinary.UpdateInstructions())
	})
}

func TestDetectInstallMethod_GoInstall(t *testing.T) {
	// Cannot use t.Parallel() with t.Setenv.

	// Test that a path in GOPATH/bin is detected as go install.
	tmpDir := t.TempDir()
	gopathBin := filepath.Join(tmpDir, "go", "bin")
	require.NoError(t, os.MkdirAll(gopathBin, 0o755))

	exePath := filepath.Join(gopathBin, "crush")
	t.Setenv("GOPATH", filepath.Join(tmpDir, "go"))

	method := detectInstallMethod(exePath)
	require.Equal(t, InstallMethodGoInstall, method)
}

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

	// Test that a path in /nix/store is detected as Nix.
	exePath := "/nix/store/abc123-crush-0.21.0/bin/crush"
	method := detectInstallMethod(exePath)
	require.Equal(t, InstallMethodNix, method)
}

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

	// Test that an unknown path returns Unknown.
	exePath := "/some/random/path/crush"
	method := detectInstallMethod(exePath)
	require.Equal(t, InstallMethodUnknown, method)
}

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

	tests := []string{
		"/usr/local/lib/node_modules/@charmland/crush/bin/crush",
		"/home/user/.npm-global/lib/node_modules/@charmland/crush/bin/crush",
		"/Users/user/node_modules/.bin/crush",
	}

	for _, exePath := range tests {
		t.Run(exePath, func(t *testing.T) {
			t.Parallel()
			method := detectInstallMethod(exePath)
			require.Equal(t, InstallMethodNPM, method)
		})
	}
}

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

	// Test paths that work across platforms (contain /Cellar/).
	tests := []struct {
		name    string
		exePath string
	}{
		{"Intel Mac Cellar", "/usr/local/Cellar/crush/0.21.0/bin/crush"},
		{"Apple Silicon Cellar", "/opt/homebrew/Cellar/crush/0.21.0/bin/crush"},
		{"Linux Homebrew Cellar", "/home/linuxbrew/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
		{"Linux Homebrew user Cellar", "/home/user/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			method := detectInstallMethod(tt.exePath)
			require.Equal(t, InstallMethodHomebrew, method)
		})
	}
}

func TestDetectInstallMethod_DefaultGoPath(t *testing.T) {
	// Cannot use t.Parallel() with t.Setenv.

	// Test detection with default GOPATH (~/go/bin) when GOPATH is not set.
	tmpDir := t.TempDir()
	home := filepath.Join(tmpDir, "home", "user")
	gopathBin := filepath.Join(home, "go", "bin")
	require.NoError(t, os.MkdirAll(gopathBin, 0o755))

	// Unset GOPATH to test default detection.
	t.Setenv("GOPATH", "")
	t.Setenv("HOME", home)

	exePath := filepath.Join(gopathBin, "crush")
	method := detectInstallMethod(exePath)
	require.Equal(t, InstallMethodGoInstall, method)
}

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

	tests := []struct {
		name  string
		input string
		want  bool
	}{
		{"valid hash", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", true},
		{"valid hash uppercase", "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", true},
		{"too short", "abc123", false},
		{"too long", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", false},
		{"invalid hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", false},
		{"empty", "", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, isValidSHA256(tt.input))
		})
	}
}

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

	validHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"

	tests := []struct {
		name  string
		input string
		want  string
	}{
		{"valid sha256 digest", "sha256:" + validHash, validHash},
		{"missing prefix", validHash, ""},
		{"wrong prefix", "sha1:" + validHash, ""},
		{"invalid hash after prefix", "sha256:invalid", ""},
		{"empty", "", ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, parseDigest(tt.input))
		})
	}
}

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

	t.Run("successful copy", func(t *testing.T) {
		t.Parallel()
		src := strings.NewReader("hello world")
		dst := &strings.Builder{}

		n, err := copyWithContext(context.Background(), dst, src)
		require.NoError(t, err)
		require.Equal(t, int64(11), n)
		require.Equal(t, "hello world", dst.String())
	})

	t.Run("cancelled context", func(t *testing.T) {
		t.Parallel()
		// Create a large source that will take multiple reads.
		src := strings.NewReader(strings.Repeat("x", 100000))
		dst := &strings.Builder{}

		ctx, cancel := context.WithCancel(context.Background())
		cancel() // Cancel immediately.

		_, err := copyWithContext(ctx, dst, src)
		require.ErrorIs(t, err, context.Canceled)
	})

	t.Run("empty source", func(t *testing.T) {
		t.Parallel()
		src := strings.NewReader("")
		dst := &strings.Builder{}

		n, err := copyWithContext(context.Background(), dst, src)
		require.NoError(t, err)
		require.Equal(t, int64(0), n)
	})
}
