package oauth

import (
	"context"
	"errors"
	"net"
	"strconv"
	"sync"
	"testing"
	"time"

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

// newManager wires a Manager to the fake GitHub server. By default the browser
// auto-opens (driving the callback) and Docker detection is off.
func newManager(t *testing.T, f *fakeGitHub) *Manager {
	t.Helper()
	cfg := Config{
		ClientID:     "client-id",
		ClientSecret: "client-secret",
		Scopes:       []string{"repo"},
		Endpoint:     f.endpoint(),
	}
	m := NewManager(cfg, testLogger())
	m.openURL = browserGet
	m.inDocker = func() bool { return false }
	return m
}

func TestAuthenticatePKCEViaBrowser(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)
	assert.Nil(t, out, "browser flow completes without a user action")
	assert.Equal(t, "gho_access", m.AccessToken())

	// PKCE must have been exercised end to end.
	f.mu.Lock()
	defer f.mu.Unlock()
	assert.Equal(t, "S256", f.codeChallengeMethod)
	assert.NotEmpty(t, f.codeChallenge, "authorize must receive a code_challenge")
	assert.NotEmpty(t, f.codeVerifier, "token exchange must send a code_verifier")
	assert.Equal(t, []string{"authorization_code"}, f.grants)
}

func TestAuthenticateRefreshesExpiringGitHubAppToken(t *testing.T) {
	f := newFakeGitHub(t)
	// GitHub App: the initial token expires immediately and carries a refresh
	// token, so the very next read must refresh transparently.
	f.authToken = "ghu_initial"
	f.authRefresh = "ghr_refresh"
	f.authExpires = 1
	f.refreshToken = "ghu_refreshed"
	f.refreshExpires = 3600

	m := newManager(t, f)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	_, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)

	assert.Equal(t, "ghu_refreshed", m.AccessToken(), "expired token must be refreshed")
	assert.Equal(t, []string{"authorization_code", "refresh_token"}, f.recordedGrants())
}

func TestAuthenticateURLElicitation(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)
	m.openURL = func(string) error { return errors.New("no browser") }

	prompter := &fakePrompter{
		urlCapable: true,
		onURL: func(_ context.Context, p Prompt) error {
			return browserGet(p.URL) // user opens the URL and authorizes
		},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, prompter)
	require.NoError(t, err)
	assert.Nil(t, out)
	assert.Equal(t, "gho_access", m.AccessToken())

	prompter.mu.Lock()
	defer prompter.mu.Unlock()
	require.Len(t, prompter.urlCalls, 1)
	assert.NotEmpty(t, prompter.urlCalls[0].URL)
}

func TestAuthenticateDeclinedPromptFails(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)
	m.openURL = func(string) error { return errors.New("no browser") }

	prompter := &fakePrompter{
		urlCapable: true,
		onURL: func(_ context.Context, _ Prompt) error {
			return ErrPromptDeclined
		},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	_, err := m.Authenticate(ctx, prompter)
	require.Error(t, err, "declining the prompt must abort the flow")
	assert.Empty(t, m.AccessToken())
}

func TestAuthenticateUndeliverablePromptFallsBack(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)
	m.openURL = func(string) error { return errors.New("no browser") }

	// The client advertised URL elicitation but delivering the prompt fails (a
	// transport/protocol error, not a user decision). This must degrade to the
	// manual instructions rather than aborting like a decline does.
	prompter := &fakePrompter{
		urlCapable: true,
		onURL: func(_ context.Context, _ Prompt) error {
			return ErrPromptUnavailable
		},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, prompter)
	require.NoError(t, err, "an undeliverable prompt must not abort the flow")
	require.NotNil(t, out)
	require.NotNil(t, out.UserAction, "an undeliverable prompt must fall back to a user action")
	assert.NotEmpty(t, out.UserAction.URL)
	assert.Contains(t, out.UserAction.Message, securityAdvisory)

	// A concurrent retry while awaiting the user returns the same fallback action.
	out2, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)
	require.NotNil(t, out2.UserAction)
	assert.Equal(t, out.UserAction.URL, out2.UserAction.URL)

	// The background flow stayed alive: opening the URL out of band completes it.
	require.NoError(t, browserGet(out.UserAction.URL))
	assert.Equal(t, "gho_access", waitForToken(t, m))
}

func TestAuthenticateLastDitchUserAction(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)
	m.openURL = func(string) error { return errors.New("no browser") }

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// No browser and a nil prompter: the only channel left is a user action
	// returned to the caller.
	out, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)
	require.NotNil(t, out)
	require.NotNil(t, out.UserAction)
	assert.NotEmpty(t, out.UserAction.URL)
	assert.Contains(t, out.UserAction.Message, "open this URL")
	assert.Contains(t, out.UserAction.Message, securityAdvisory,
		"missing URL elicitation should trigger the security advisory")

	// A concurrent retry while awaiting the user returns the same action, not a
	// second flow.
	out2, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)
	require.NotNil(t, out2.UserAction)
	assert.Equal(t, out.UserAction.URL, out2.UserAction.URL)

	// The user opens the URL out of band; the background flow then completes.
	require.NoError(t, browserGet(out.UserAction.URL))
	assert.Equal(t, "gho_access", waitForToken(t, m))
}

func TestAuthenticateDeviceFlow(t *testing.T) {
	f := newFakeGitHub(t)
	f.deviceToken = "gho_device_token"
	m := newManager(t, f)
	// Inside Docker with a random port, PKCE is impossible, so the device flow
	// is selected.
	m.inDocker = func() bool { return true }
	m.openURL = func(string) error { return errors.New("no browser") }

	prompter := &fakePrompter{urlCapable: true} // shows the code, no action needed

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, prompter)
	require.NoError(t, err)
	assert.Nil(t, out)
	assert.Equal(t, "gho_device_token", m.AccessToken())
	assert.Contains(t, f.recordedGrants(), "urn:ietf:params:oauth:grant-type:device_code")
}

func TestAuthenticateDevicePollingPending(t *testing.T) {
	f := newFakeGitHub(t)
	f.deviceToken = "gho_device_token"
	f.devicePending = 1 // one authorization_pending before success
	m := newManager(t, f)
	m.inDocker = func() bool { return true }
	m.openURL = func(string) error { return errors.New("no browser") }

	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
	defer cancel()

	_, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
	require.NoError(t, err)
	assert.Equal(t, "gho_device_token", m.AccessToken())
}

func TestAuthenticateHeadlessPrefersDeviceFlow(t *testing.T) {
	f := newFakeGitHub(t)
	f.deviceToken = "gho_device_token"
	m := newManager(t, f)
	// A headless host (no display server) with a random callback port: a PKCE
	// redirect to this machine's localhost is unreachable from a browser on
	// another machine, so device flow must be chosen even though the client can
	// elicit a URL (which would otherwise win over device flow).
	m.openURL = func(string) error { return errNoDisplay }

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
	require.NoError(t, err)
	assert.Nil(t, out)
	assert.Equal(t, "gho_device_token", m.AccessToken())
	grants := f.recordedGrants()
	assert.Contains(t, grants, "urn:ietf:params:oauth:grant-type:device_code")
	assert.NotContains(t, grants, "authorization_code",
		"headless host must skip the unreachable PKCE authorization-code flow")
}

func TestAuthenticateFixedCallbackPortUnavailableIsFatal(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)

	// Occupy the fixed callback port so the OAuth listener cannot bind it. A held
	// port could belong to another user's process that would receive the redirect,
	// so the flow must fail loudly rather than quietly downgrade to device flow.
	squatter, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer squatter.Close()
	port := squatter.Addr().(*net.TCPAddr).Port
	m.config.CallbackPort = port

	// A browser that would have completed PKCE, proving the abort is caused by the
	// unavailable port and not by a missing display channel.
	m.openURL = browserGet

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true})
	require.Error(t, err)
	assert.Nil(t, out)
	assert.Contains(t, err.Error(), strconv.Itoa(port))
	assert.Empty(t, m.AccessToken())
	// The decisive check: no device-code grant was attempted, so the flow did not
	// silently fall back when the deliberately chosen port was unavailable.
	assert.Empty(t, f.recordedGrants(), "fixed-port bind failure must not fall back to device flow")
}

func TestAuthenticateNoTokenInitially(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)
	assert.False(t, m.HasToken())
	assert.Empty(t, m.AccessToken())
}

func TestAuthenticateSingleFlight(t *testing.T) {
	f := newFakeGitHub(t)
	m := newManager(t, f)

	// Hold the owner inside begin() (browser open blocks) so a concurrent caller
	// observes the in-progress flow rather than starting its own.
	entered := make(chan struct{})
	release := make(chan struct{})
	var once sync.Once
	m.openURL = func(u string) error {
		once.Do(func() { close(entered) })
		<-release
		return browserGet(u)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	ownerDone := make(chan error, 1)
	go func() {
		_, err := m.Authenticate(ctx, nil)
		ownerDone <- err
	}()

	<-entered // owner is now mid-flow with status "starting"

	out, err := m.Authenticate(ctx, nil)
	require.NoError(t, err)
	require.NotNil(t, out.UserAction)
	assert.Contains(t, out.UserAction.Message, "already in progress")

	close(release)
	require.NoError(t, <-ownerDone)

	assert.Equal(t, "gho_access", waitForToken(t, m))
	// Exactly one authorization happened despite the concurrent callers.
	assert.Equal(t, []string{"authorization_code"}, f.recordedGrants())
}
