package oidc_test

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
	"time"

	josev4 "github.com/go-jose/go-jose/v4"
	"github.com/stretchr/testify/require"

	"git.d-ma.be/mathias/tapir/internal/web"
	"git.d-ma.be/mathias/tapir/internal/web/oidc"
)

const (
	testClientID = "tapir-web"
	testSubject  = "dex-subject-123"
)

// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
// document, a JWKS, and a token endpoint that mints an RS256-signed ID token
// from its mutable sub/email/nonce fields. No live Dex.
type fakeIssuer struct {
	server   *httptest.Server
	key      *rsa.PrivateKey
	clientID string

	sub   string
	email string
	nonce string
}

const testKID = "test-key"

func newFakeIssuer(t *testing.T) *fakeIssuer {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)

	f := &fakeIssuer{key: key, clientID: testClientID}

	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(t, w, map[string]any{
			"issuer":                                f.server.URL,
			"authorization_endpoint":                f.server.URL + "/authorize",
			"token_endpoint":                        f.server.URL + "/token",
			"jwks_uri":                              f.server.URL + "/jwks",
			"id_token_signing_alg_values_supported": []string{"RS256"},
			"response_types_supported":              []string{"code"},
			"subject_types_supported":               []string{"public"},
		})
	})
	mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(t, w, josev4.JSONWebKeySet{Keys: []josev4.JSONWebKey{{
			Key:       &f.key.PublicKey,
			KeyID:     testKID,
			Algorithm: "RS256",
			Use:       "sig",
		}}})
	})
	mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(t, w, map[string]any{
			"access_token": "fake-access-token",
			"token_type":   "Bearer",
			"expires_in":   3600,
			"id_token":     f.signIDToken(t),
		})
	})

	f.server = httptest.NewServer(mux)
	t.Cleanup(f.server.Close)
	return f
}

func (f *fakeIssuer) signIDToken(t *testing.T) string {
	t.Helper()
	signer, err := josev4.NewSigner(
		josev4.SigningKey{Algorithm: josev4.RS256, Key: f.key},
		(&josev4.SignerOptions{}).WithType("JWT").WithHeader("kid", testKID),
	)
	require.NoError(t, err)

	now := time.Now()
	payload, err := json.Marshal(map[string]any{
		"iss":   f.server.URL,
		"sub":   f.sub,
		"aud":   f.clientID,
		"exp":   now.Add(time.Hour).Unix(),
		"iat":   now.Unix(),
		"nonce": f.nonce,
		"email": f.email,
	})
	require.NoError(t, err)

	obj, err := signer.Sign(payload)
	require.NoError(t, err)
	s, err := obj.CompactSerialize()
	require.NoError(t, err)
	return s
}

func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
	t.Helper()
	w.Header().Set("Content-Type", "application/json")
	require.NoError(t, json.NewEncoder(w).Encode(v))
}

func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
	t.Helper()
	auth, err := oidc.New(context.Background(), oidc.Config{
		Issuer:        f.server.URL,
		ClientID:      testClientID,
		ClientSecret:  "test-client-secret",
		RedirectURL:   "http://tapir.test/auth/callback",
		SessionSecret: "test-session-secret-please-change",
	}, oidc.WithInsecureCookies())
	require.NoError(t, err)
	return auth
}

// login drives /auth/login and returns the state and nonce from the authorize
// redirect, as a real browser hop would surface them.
func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
	t.Helper()
	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
	require.Equal(t, http.StatusFound, rec.Code)

	loc, err := url.Parse(rec.Header().Get("Location"))
	require.NoError(t, err)
	q := loc.Query()
	return q.Get("state"), q.Get("nonce")
}

// authenticate completes a full login+callback for the test subject and returns
// the resulting session cookie.
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
	t.Helper()
	state, nonce := login(t, auth)
	f.sub, f.email, f.nonce = testSubject, "maintainer@d-ma.be", nonce

	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/auth/callback?code=valid-code&state="+state, nil))
	require.Equal(t, http.StatusFound, rec.Code)
	require.Equal(t, "/", rec.Header().Get("Location"))

	c := sessionCookie(t, rec.Result())
	require.NotEmpty(t, c.Value)
	return c
}

func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie {
	t.Helper()
	for _, c := range resp.Cookies() {
		if c.Name == "tapir_session" {
			return c
		}
	}
	t.Fatal("no tapir_session cookie set")
	return nil
}

func TestLoginRedirectsToAuthorize(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))

	require.Equal(t, http.StatusFound, rec.Code)
	loc, err := url.Parse(rec.Header().Get("Location"))
	require.NoError(t, err)
	require.Equal(t, f.server.URL+"/authorize", loc.Scheme+"://"+loc.Host+loc.Path)

	q := loc.Query()
	require.Equal(t, "code", q.Get("response_type"))
	require.Equal(t, testClientID, q.Get("client_id"))
	require.NotEmpty(t, q.Get("state"))
	require.NotEmpty(t, q.Get("nonce"))
	require.Contains(t, q.Get("scope"), "openid")
}

func TestCallbackSetsSession(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	cookie := authenticate(t, auth, f)

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(cookie)
	user, ok := auth.CurrentUser(req)
	require.True(t, ok)
	require.Equal(t, testSubject, user.Subject)
	require.Equal(t, "maintainer@d-ma.be", user.Email)

	require.True(t, cookie.HttpOnly)
	require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}

// TestCallbackAnySubjectAuthenticates proves the single-subject allowlist is gone
// (ADR-012): a subject other than any prior allowlist still gets a session.
func TestCallbackAnySubjectAuthenticates(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	state, nonce := login(t, auth)
	f.sub, f.email, f.nonce = "some-other-subject-999", "other@elsewhere.test", nonce

	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/auth/callback?code=valid-code&state="+state, nil))

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

	cookie := sessionCookie(t, rec.Result())
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(cookie)
	user, ok := auth.CurrentUser(req)
	require.True(t, ok)
	require.Equal(t, "some-other-subject-999", user.Subject)
}

func TestCallbackUnknownStateRejected(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/auth/callback?code=valid-code&state=forged-state", nil))

	require.Equal(t, http.StatusBadRequest, rec.Code)
}

func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	// The bare root sends an unauthenticated visitor to the public landing page.
	rec := httptest.NewRecorder()
	guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
	require.Equal(t, http.StatusFound, rec.Code)
	require.Equal(t, "/welcome", rec.Header().Get("Location"))

	// A deeper guarded path goes to login so the post-login round-trip returns there.
	rec = httptest.NewRecorder()
	guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
	require.Equal(t, http.StatusFound, rec.Code)
	require.Equal(t, "/auth/login", rec.Header().Get("Location"))
}

func TestMiddlewarePassesAuthenticated(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)
	cookie := authenticate(t, auth, f)

	guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusTeapot) // a marker the handler actually ran
	}))

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(cookie)
	rec := httptest.NewRecorder()
	guarded.ServeHTTP(rec, req)

	require.Equal(t, http.StatusTeapot, rec.Code)
}

func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)

	guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
		rec := httptest.NewRecorder()
		guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
		require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
	}
}

func TestLogoutClearsSession(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)
	cookie := authenticate(t, auth, f)

	req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
	req.AddCookie(cookie)
	rec := httptest.NewRecorder()
	auth.Routes().ServeHTTP(rec, req)

	require.Equal(t, http.StatusFound, rec.Code)
	require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
	cleared := sessionCookie(t, rec.Result())
	require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it")
	require.Empty(t, cleared.Value, "logout blanks the cookie value")

	// Sessions are stateless (ADR-029): logout clears the cookie client-side, so a
	// request carrying the cleared (empty) cookie is unauthenticated. The original
	// signed cookie remains technically valid until its expiry — the accepted
	// trade for no server-side store; the browser no longer holds it.
	check := httptest.NewRequest(http.MethodGet, "/", nil)
	check.AddCookie(cleared)
	_, ok := auth.CurrentUser(check)
	require.False(t, ok, "the cleared cookie does not authenticate")
}

// TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one
// process is accepted by a FRESH instance with the same session secret — so a
// deploy/pod-restart no longer logs users out (the old in-memory store did).
func TestSessionSurvivesRestart(t *testing.T) {
	f := newFakeIssuer(t)
	auth1 := newAuth(t, f)
	cookie := authenticate(t, auth1, f)

	auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(cookie)
	user, ok := auth2.CurrentUser(req)
	require.True(t, ok, "a session must survive a restart (stateless signed cookie)")
	require.Equal(t, testSubject, user.Subject)
}

// TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it
// survives the browser/app being closed (a session cookie was dropped on iOS).
func TestSessionCookieIsPersistent(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)
	cookie := authenticate(t, auth, f)
	require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)")
}

func TestExpiredSessionRejected(t *testing.T) {
	f := newFakeIssuer(t)
	clock := time.Now()
	auth, err := oidc.New(context.Background(), oidc.Config{
		Issuer:        f.server.URL,
		ClientID:      testClientID,
		ClientSecret:  "test-client-secret",
		RedirectURL:   "http://tapir.test/auth/callback",
		SessionSecret: "test-session-secret-please-change",
	}, oidc.WithInsecureCookies(),
		oidc.WithSessionTTL(time.Minute),
		oidc.WithClock(func() time.Time { return clock }))
	require.NoError(t, err)

	cookie := authenticate(t, auth, f)

	clock = clock.Add(2 * time.Minute) // push past the TTL

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(cookie)
	_, ok := auth.CurrentUser(req)
	require.False(t, ok)
}

func TestTamperedCookieRejected(t *testing.T) {
	f := newFakeIssuer(t)
	auth := newAuth(t, f)
	cookie := authenticate(t, auth, f)

	tampered := &http.Cookie{Name: cookie.Name, Value: cookie.Value + "x"}
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(tampered)
	_, ok := auth.CurrentUser(req)
	require.False(t, ok)
}

var _ web.Auth = (*oidc.DexAuth)(nil)
