package dashboard

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"
)

func TestDetectToolchainFindsBunFromBUNInstallWhenPathMissing(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses a POSIX shell script as the Bun stub")
	}

	installDir := t.TempDir()
	bunPath := writeBunStub(t, installDir)
	t.Setenv("BUN_INSTALL", installDir)
	t.Setenv("PATH", "/usr/bin:/bin")

	info := detectToolchain(ToolchainBun)
	require.True(t, info.Found)
	require.Equal(t, bunPath, info.Path)
	require.Equal(t, "1.2.3-test", info.Version)
}

func TestDetectToolchainFindsGoFromManagedInstallDirWhenPathMissing(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses a POSIX shell script as the Go stub")
	}

	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)
	t.Setenv("PATH", "/usr/bin:/bin")

	goPath := writeManagedGoStub(t, homeDir)
	info := detectToolchain(ToolchainGo)
	require.True(t, info.Found)
	require.Equal(t, goPath, info.Path)
	require.Contains(t, info.Version, "go version go1.25.0")
}

func TestRefreshInstalledToolchainPathPrependsDetectedBunDir(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses a POSIX shell script as the Bun stub")
	}

	installDir := t.TempDir()
	bunPath := writeBunStub(t, installDir)
	binDir := filepath.Dir(bunPath)
	t.Setenv("BUN_INSTALL", installDir)
	t.Setenv("PATH", "/usr/bin:/bin")

	var output bytes.Buffer
	require.NoError(t, refreshInstalledToolchainPath(ToolchainBun, &output))
	pathEntries := filepath.SplitList(os.Getenv("PATH"))
	require.NotEmpty(t, pathEntries)
	require.Equal(t, binDir, pathEntries[0])
	require.Contains(t, output.String(), "Bun available at "+bunPath)
}

func TestRefreshInstalledToolchainPathPrependsDetectedGoDir(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses a POSIX shell script as the Go stub")
	}

	homeDir := t.TempDir()
	goPath := writeManagedGoStub(t, homeDir)
	binDir := filepath.Dir(goPath)
	t.Setenv("HOME", homeDir)
	t.Setenv("PATH", "/usr/bin:/bin")

	var output bytes.Buffer
	require.NoError(t, refreshInstalledToolchainPath(ToolchainGo, &output))
	pathEntries := filepath.SplitList(os.Getenv("PATH"))
	require.NotEmpty(t, pathEntries)
	require.Equal(t, binDir, pathEntries[0])
	require.Contains(t, output.String(), "Go available at "+goPath)
}

func TestBunInstallEnvironmentSetsDefaultInstallRoot(t *testing.T) {
	t.Setenv("BUN_INSTALL", "")
	env := bunInstallEnvironment()

	var found string
	for _, entry := range env {
		if strings.HasPrefix(entry, "BUN_INSTALL=") {
			found = strings.TrimPrefix(entry, "BUN_INSTALL=")
			break
		}
	}
	require.NotEmpty(t, found)
	require.True(t, strings.HasSuffix(found, filepath.Join(".bun")))
}

func TestDetectToolchainLatestGoUsesJSONArrayPayload(t *testing.T) {
	restoreGoDownloadMetadataFetcher(t, "go1.25.0")

	require.Equal(t, "go1.25.0", detectToolchainLatest(ToolchainGo))
}

func TestGoDownloadURLFallsBackToLatestAlias(t *testing.T) {
	require.Equal(t,
		"https://go.dev/dl/latest.darwin-arm64.tar.gz",
		goDownloadURL("", "darwin", "arm64"),
	)
	require.Equal(t,
		"https://go.dev/dl/go1.25.0.darwin-arm64.tar.gz",
		goDownloadURL("1.25.0", "darwin", "arm64"),
	)
	require.Equal(t,
		"https://go.dev/dl/go1.25.0.linux-amd64.tar.gz",
		goDownloadURL("go1.25.0", "linux", "amd64"),
	)
}

func TestExplainWindowsGoInstallFailureTimeout(t *testing.T) {
	err := explainWindowsGoInstallFailure("winget", errors.New("exit status 0x80072ee2"))
	require.Error(t, err)
	require.Contains(t, err.Error(), "timed out")
	require.Contains(t, err.Error(), "network, proxy, VPN")
	require.Contains(t, err.Error(), "0x80072ee2")
}

func TestExplainWindowsGoInstallFailureTLS(t *testing.T) {
	err := explainWindowsGoInstallFailure("winget", errors.New("InternetOpenUrl() failed. 0x80072f19"))
	require.Error(t, err)
	require.Contains(t, err.Error(), "trusted HTTPS connection")
	require.Contains(t, err.Error(), "certificate trust")
	require.Contains(t, err.Error(), "0x80072f19")
}

func TestExplainWindowsGoInstallFailureFallback(t *testing.T) {
	err := explainWindowsGoInstallFailure("scoop", errors.New("exit status 1"))
	require.Error(t, err)
	require.Contains(t, err.Error(), "Review the installer output above")
	require.Contains(t, err.Error(), "exit status 1")
}

func TestStreamToolchainOperationStopsWhenCanceledWhileWaitingForInstallSlot(t *testing.T) {
	gin.SetMode(gin.TestMode)
	select {
	case toolchainInstallSlots <- struct{}{}:
		t.Cleanup(func() { <-toolchainInstallSlots })
	default:
		t.Fatal("toolchain install slot already held")
	}

	ctx, cancel := context.WithCancel(context.Background())
	rec := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(rec)
	c.Request = httptest.NewRequest(http.MethodPost, "/toolchain/go/install", nil).WithContext(ctx)

	done := make(chan struct{})
	runCalled := make(chan struct{}, 1)
	go func() {
		defer close(done)
		streamToolchainOperation(c, func(context.Context, io.Writer) error {
			runCalled <- struct{}{}
			return nil
		})
	}()

	cancel()
	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("streamToolchainOperation did not stop after request cancellation")
	}
	select {
	case <-runCalled:
		t.Fatal("toolchain operation started while install slot was unavailable")
	default:
	}
}

func TestStreamToolchainOperationHandlesLongLogLine(t *testing.T) {
	gin.SetMode(gin.TestMode)

	rec := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(rec)
	c.Request = httptest.NewRequest(http.MethodPost, "/toolchain/go/install", nil)

	longLog := strings.Repeat("x", 128*1024)
	done := make(chan struct{})
	go func() {
		defer close(done)
		streamToolchainOperation(c, func(_ context.Context, w io.Writer) error {
			_, err := fmt.Fprintln(w, longLog)
			return err
		})
	}()

	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("streamToolchainOperation did not finish after a long log line")
	}

	body := rec.Body.String()
	require.Contains(t, body, "event: log")
	require.Contains(t, body, "event: done")
	require.NotContains(t, body, "event: error")

	select {
	case toolchainInstallSlots <- struct{}{}:
		<-toolchainInstallSlots
	default:
		t.Fatal("toolchain install slot was not released")
	}
}

func TestRunPipedToWriterStopsOnCanceledContext(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses POSIX shell commands")
	}

	dir := t.TempDir()
	ready := filepath.Join(dir, "child-started")
	marker := filepath.Join(dir, "child-survived")
	childScript := fmt.Sprintf("(printf started > %q; sleep 0.5; printf survived > %q) & wait", ready, marker)
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() {
		done <- runPipedToWriter(
			ctx,
			io.Discard,
			[]string{"sh", "-c", childScript},
			[]string{"sh", "-c", "cat >/dev/null"},
		)
	}()

	require.Eventually(t, func() bool {
		if _, err := os.Stat(ready); err == nil {
			return true
		} else if !errors.Is(err, os.ErrNotExist) {
			t.Fatalf("stat child ready marker: %v", err)
		}
		return false
	}, time.Second, 10*time.Millisecond, "runPipedToWriter child command did not start")
	cancel()
	select {
	case err := <-done:
		require.Error(t, err)
	case <-time.After(2 * time.Second):
		t.Fatal("runPipedToWriter did not stop after context cancellation")
	}

	require.Never(t, func() bool {
		if _, err := os.Stat(marker); err == nil {
			return true
		} else if !errors.Is(err, os.ErrNotExist) {
			t.Fatalf("stat child marker: %v", err)
		}
		return false
	}, time.Second, 10*time.Millisecond, "runPipedToWriter cancellation left a child process running")
}

func TestInstallGoFromOfficialPreservesExistingManagedGoOnExtractFailure(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("official managed Go tarball install is POSIX-only")
	}

	restoreGoDownloadMetadataFetcher(t, "go1.25.0")
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)
	fakeBin := t.TempDir()
	writeExecutable(t, fakeBin, "curl", "#!/bin/sh\nprintf 'fake archive'\n")
	writeExecutable(t, fakeBin, "tar", "#!/bin/sh\nexit 42\n")
	t.Setenv("PATH", fakeBin)

	goPath := writeManagedGoStub(t, homeDir)
	oldGo := []byte("#!/bin/sh\necho 'go version go1.24.0 old/old'\n")
	require.NoError(t, os.WriteFile(goPath, oldGo, 0o755))

	var output bytes.Buffer
	err := installGoFromOfficial(context.Background(), &output)
	require.Error(t, err)
	require.Contains(t, err.Error(), "failed to download/extract Go")

	got, readErr := os.ReadFile(goPath)
	require.NoError(t, readErr)
	require.Equal(t, string(oldGo), string(got))
}

func TestInstallGoFromOfficialPreservesExistingManagedGoWhenArchiveMissesBinary(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("official managed Go tarball install is POSIX-only")
	}

	restoreGoDownloadMetadataFetcher(t, "go1.25.0")
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)
	fakeBin := t.TempDir()
	writeExecutable(t, fakeBin, "curl", "#!/bin/sh\nprintf 'fake archive'\n")
	writeExecutable(t, fakeBin, "tar", `#!/bin/sh
dest=
while [ "$#" -gt 0 ]; do
  if [ "$1" = "-C" ]; then
    dest="$2"
    shift 2
    continue
  fi
  shift
done
/bin/cat >/dev/null
/bin/mkdir -p "$dest/go"
`)
	t.Setenv("PATH", fakeBin)

	goPath := writeManagedGoStub(t, homeDir)
	oldGo := []byte("#!/bin/sh\necho 'go version go1.24.0 old/old'\n")
	require.NoError(t, os.WriteFile(goPath, oldGo, 0o755))

	var output bytes.Buffer
	err := installGoFromOfficial(context.Background(), &output)
	require.ErrorContains(t, err, "verify extracted Go binary")

	got, readErr := os.ReadFile(goPath)
	require.NoError(t, readErr)
	require.Equal(t, string(oldGo), string(got))
}

func TestInstallGoActivatesManagedGoBeforeOlderPathGo(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("official managed Go tarball install is POSIX-only")
	}

	restoreGoDownloadMetadataFetcher(t, "go1.25.0")
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)

	oldBin := t.TempDir()
	oldGo := writeExecutable(t, oldBin, "go", "#!/bin/sh\necho 'go version go1.20.0 old/old'\n")
	fakeBin := t.TempDir()
	writeExecutable(t, fakeBin, "curl", "#!/bin/sh\nprintf 'fake archive'\n")
	writeExecutable(t, fakeBin, "tar", `#!/bin/sh
dest=
while [ "$#" -gt 0 ]; do
  if [ "$1" = "-C" ]; then
    dest="$2"
    shift 2
    continue
  fi
  shift
done
/bin/cat >/dev/null
/bin/mkdir -p "$dest/go/bin"
/bin/cat > "$dest/go/bin/go" <<'EOF'
#!/bin/sh
echo 'go version go1.25.0 new/new'
EOF
/bin/chmod +x "$dest/go/bin/go"
`)
	t.Setenv("PATH", oldBin+string(os.PathListSeparator)+fakeBin)

	var output bytes.Buffer
	require.NoError(t, installToolchain(context.Background(), ToolchainGo, &output))

	info := detectToolchain(ToolchainGo)
	require.True(t, info.Found)
	require.NotEqual(t, oldGo, info.Path)
	require.Contains(t, info.Path, filepath.Join("agentteam", "toolchains", "go", "bin", "go"))
	require.Contains(t, output.String(), "Go available at "+info.Path)
	require.Equal(t, filepath.Dir(info.Path), filepath.SplitList(os.Getenv("PATH"))[0])
}

func writeBunStub(t *testing.T, installDir string) string {
	t.Helper()
	binDir := filepath.Join(installDir, "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	bunPath := filepath.Join(binDir, "bun")
	require.NoError(t, os.WriteFile(bunPath, []byte("#!/bin/sh\necho 1.2.3-test\n"), 0o755))
	return bunPath
}

func writeManagedGoStub(t *testing.T, homeDir string) string {
	t.Helper()

	installDir := filepath.Join(homeDir, ".local", "share", "agentteam", "toolchains")
	if runtime.GOOS == "darwin" {
		installDir = filepath.Join(homeDir, "Library", "Application Support", "agentteam", "toolchains")
	}
	binDir := filepath.Join(installDir, "go", "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	goPath := filepath.Join(binDir, "go")
	require.NoError(t, os.WriteFile(goPath, []byte("#!/bin/sh\necho 'go version go1.25.0 test/test'\n"), 0o755))
	return goPath
}

func restoreGoDownloadMetadataFetcher(t *testing.T, version string) {
	t.Helper()

	originalFetcher := goDownloadMetadataFetcher
	t.Cleanup(func() { goDownloadMetadataFetcher = originalFetcher })
	goDownloadMetadataFetcher = func(context.Context) (string, error) {
		return version, nil
	}
}

func writeExecutable(t *testing.T, dir string, name string, content string) string {
	t.Helper()

	path := filepath.Join(dir, name)
	require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
	return path
}
