package web_test

import (
	"context"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"
	"golang.org/x/oauth2"

	"git.d-ma.be/mathias/tapir/internal/adapters/store"
	"git.d-ma.be/mathias/tapir/internal/auth"
	"git.d-ma.be/mathias/tapir/internal/web"
)

// fakeWriter is a TokenWriter capturing the persisted (ref, value).
type fakeWriter struct {
	mu       sync.Mutex
	ref, val string
	calls    int
}

func (w *fakeWriter) Put(ref, value string) error {
	w.mu.Lock()
	defer w.mu.Unlock()
	w.ref, w.val, w.calls = ref, value, w.calls+1
	return nil
}

// fakeConns captures UpsertConnection calls without a database.
type fakeConns struct {
	mu     sync.Mutex
	calls  int
	userID string
	conn   store.Connection
}

func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn store.Connection) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.calls, c.userID, c.conn = c.calls+1, userID, conn
	return nil
}

// fakeTrigger records Enqueue calls so a test can assert connect fired discovery.
type fakeTrigger struct {
	mu    sync.Mutex
	users []string
}

func (f *fakeTrigger) Enqueue(userID string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.users = append(f.users, userID)
}

func (f *fakeTrigger) seen() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]string(nil), f.users...)
}

// tokenServer fakes Google's token endpoint, returning body for any POST.
func tokenServer(t *testing.T, body string) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(body))
	}))
	t.Cleanup(srv.Close)
	return srv
}

// newConnectApp builds a registered-stub-user App with a wired ConnectHandler.
// The OAuth endpoint points at srvURL so Exchange never contacts live Google.
func newConnectApp(t *testing.T, srvURL string, secrets auth.TokenWriter, conns web.Connections) *web.App {
	t.Helper()
	s := newStore(t)       // applies migrations
	resetDB(t, rawPool(t)) // seeds stubSubject -> userID so the gate resolves a user
	connect := web.NewConnectHandler(auth.Config{
		ClientID:     "cid",
		ClientSecret: "csecret",
		RedirectURL:  "https://tapir.d-ma.be/oauth/youtube/callback",
		Endpoint:     oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
	}, secrets, conns, nil)
	return &web.App{
		Store:    s,
		Identity: s,
		Auth:     web.StubAuth{U: web.User{Subject: stubSubject}},
		Connect:  connect,
	}
}

// connectState drives GET /oauth/youtube/connect and returns the CSRF state from
// the consent redirect, so the callback test can present a valid state.
func connectState(t *testing.T, app *web.App) string {
	t.Helper()
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/oauth/youtube/connect", nil))
	require.Equal(t, http.StatusFound, rec.Code)
	loc := rec.Header().Get("Location")
	u, err := url.Parse(loc)
	require.NoError(t, err)
	q := u.Query()
	require.Equal(t, "offline", q.Get("access_type"), "must request offline access for a refresh token")
	require.Equal(t, "consent", q.Get("prompt"), "must force consent for a refresh token")
	state := q.Get("state")
	require.NotEmpty(t, state, "consent URL must carry a CSRF state")
	return state
}

func TestConnectRedirectsToConsent(t *testing.T) {
	srv := tokenServer(t, `{}`)
	app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
	_ = connectState(t, app) // assertions live in the helper
}

func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
	w := &fakeWriter{}
	conns := &fakeConns{}
	app := newConnectApp(t, srv.URL, w, conns)

	state := connectState(t, app)
	rec := do(t, app, httptest.NewRequest(http.MethodGet,
		"/oauth/youtube/callback?state="+state+"&code=the-code", nil))

	require.Equal(t, http.StatusSeeOther, rec.Code)
	require.Equal(t, "/", rec.Header().Get("Location"))

	// Token persisted under the per-user ref.
	wantRef := web.YouTubeTokenRef(userID)
	require.Equal(t, wantRef, w.ref, "refresh token stored under the per-user ref")
	require.Equal(t, "rt-secret", w.val)

	// Connection recorded for the authenticated user.
	require.Equal(t, 1, conns.calls)
	require.Equal(t, userID, conns.userID)
	require.Equal(t, "youtube", conns.conn.Provider)
	require.Equal(t, "active", conns.conn.Status)
	require.Equal(t, wantRef, conns.conn.TokenRef)
}

func TestCallbackTriggersDiscovery(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
	app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
	trig := &fakeTrigger{}
	app.Connect.Discovery = trig

	state := connectState(t, app)
	rec := do(t, app, httptest.NewRequest(http.MethodGet,
		"/oauth/youtube/callback?state="+state+"&code=the-code", nil))

	require.Equal(t, http.StatusSeeOther, rec.Code)
	require.Equal(t, []string{userID}, trig.seen(),
		"a successful connect must trigger discovery for the connecting user")
}

func TestCallbackNoDiscoveryOnFailedConnect(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
	app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
	trig := &fakeTrigger{}
	app.Connect.Discovery = trig

	// No state → CSRF reject → nothing connected, so no discovery.
	rec := do(t, app, httptest.NewRequest(http.MethodGet,
		"/oauth/youtube/callback?code=the-code", nil))
	require.Equal(t, http.StatusBadRequest, rec.Code)
	require.Empty(t, trig.seen(), "a failed connect must not trigger discovery")
}

func TestCallbackRejectsMissingState(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
	w := &fakeWriter{}
	conns := &fakeConns{}
	app := newConnectApp(t, srv.URL, w, conns)

	rec := do(t, app, httptest.NewRequest(http.MethodGet,
		"/oauth/youtube/callback?code=the-code", nil)) // no state
	require.Equal(t, http.StatusBadRequest, rec.Code)
	require.Equal(t, 0, w.calls, "nothing persisted on missing state")
	require.Equal(t, 0, conns.calls, "no connection recorded on missing state")
}

func TestCallbackRejectsUnknownState(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
	w := &fakeWriter{}
	conns := &fakeConns{}
	app := newConnectApp(t, srv.URL, w, conns)

	// A state never issued by connect must be rejected (CSRF).
	rec := do(t, app, httptest.NewRequest(http.MethodGet,
		"/oauth/youtube/callback?state=deadbeef&code=the-code", nil))
	require.Equal(t, http.StatusBadRequest, rec.Code)
	require.Equal(t, 0, w.calls)
	require.Equal(t, 0, conns.calls)
}

func TestCallbackStateIsSingleUse(t *testing.T) {
	srv := tokenServer(t,
		`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
	w := &fakeWriter{}
	conns := &fakeConns{}
	app := newConnectApp(t, srv.URL, w, conns)

	state := connectState(t, app)
	url := "/oauth/youtube/callback?state=" + state + "&code=the-code"

	rec := do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
	require.Equal(t, http.StatusSeeOther, rec.Code)

	// Replaying the same state must fail — it was consumed.
	rec = do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
	require.Equal(t, http.StatusBadRequest, rec.Code, "state is single-use")
	require.Equal(t, 1, conns.calls, "replay must not record a second connection")
}
