//go:build integration

package httpx

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"math/big"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"bountyboard/internal/domain"
)

// fakeIdP is a minimal OIDC provider: discovery, JWKS, and a token endpoint
// that mints RS256 id_tokens for whatever identity the test configures.
type fakeIdP struct {
	srv      *httptest.Server
	key      *rsa.PrivateKey
	clientID string

	// identity returned by the next token exchange
	sub           string
	email         string
	emailVerified bool
	name          string
}

func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	idp := &fakeIdP{key: key, clientID: clientID}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{
			"issuer":                                idp.srv.URL,
			"authorization_endpoint":                idp.srv.URL + "/authorize",
			"token_endpoint":                        idp.srv.URL + "/token",
			"jwks_uri":                              idp.srv.URL + "/jwks",
			"id_token_signing_alg_values_supported": []string{"RS256"},
			"response_types_supported":              []string{"code"},
			"subject_types_supported":               []string{"public"},
		})
	})
	mux.HandleFunc("GET /jwks", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		pub := key.Public().(*rsa.PublicKey)
		json.NewEncoder(w).Encode(map[string]any{
			"keys": []map[string]any{{
				"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test",
				"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
				"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
			}},
		})
	})
	mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
		if err := r.ParseForm(); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		// PKCE: a verifier must be present (the app always sends one).
		if r.PostForm.Get("code_verifier") == "" {
			http.Error(w, "missing code_verifier", http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{
			"access_token": "fake-access-token",
			"token_type":   "Bearer",
			"id_token":     idp.mintIDToken(t),
		})
	})
	idp.srv = httptest.NewServer(mux)
	t.Cleanup(idp.srv.Close)
	return idp
}

func (idp *fakeIdP) mintIDToken(t *testing.T) string {
	t.Helper()
	header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"})
	payload, _ := json.Marshal(map[string]any{
		"iss":            idp.srv.URL,
		"sub":            idp.sub,
		"aud":            idp.clientID,
		"exp":            time.Now().Add(time.Hour).Unix(),
		"iat":            time.Now().Unix(),
		"email":          idp.email,
		"email_verified": idp.emailVerified,
		"name":           idp.name,
	})
	signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
		base64.RawURLEncoding.EncodeToString(payload)
	digest := sha256.Sum256([]byte(signingInput))
	sig, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:])
	if err != nil {
		t.Fatal(err)
	}
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}

// oidcLogin walks the full browser flow and returns the final redirect
// location after the callback.
func oidcLogin(t *testing.T, c *http.Client, appURL string) string {
	t.Helper()
	resp, err := c.Get(appURL + "/api/v1/auth/oidc/login")
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusFound {
		t.Fatalf("oidc login: %d", resp.StatusCode)
	}
	loc, err := url.Parse(resp.Header.Get("Location"))
	if err != nil {
		t.Fatal(err)
	}
	q := loc.Query()
	if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
		t.Fatalf("authorize URL missing PKCE: %s", loc)
	}
	state := q.Get("state")
	if state == "" {
		t.Fatal("authorize URL missing state")
	}

	cb := fmt.Sprintf("%s/api/v1/auth/oidc/callback?code=fake-code&state=%s",
		appURL, url.QueryEscape(state))
	resp, err = c.Get(cb)
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusFound {
		t.Fatalf("callback: %d", resp.StatusCode)
	}
	return resp.Header.Get("Location")
}

func meUser(t *testing.T, c *http.Client, appURL string) (*domain.User, int) {
	t.Helper()
	resp, err := c.Get(appURL + "/api/v1/auth/me")
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		resp.Body.Close()
		return nil, resp.StatusCode
	}
	var out struct {
		User domain.User `json:"user"`
	}
	bodyJSON(t, resp, &out)
	return &out.User, http.StatusOK
}

func TestOIDCFullFlow(t *testing.T) {
	const clientID = "bountyboard-test"
	idp := newFakeIdP(t, clientID)
	ts, _, _, _ := newAuthStack(t, map[string]string{
		"OIDC_ISSUER_URL":    idp.srv.URL,
		"OIDC_CLIENT_ID":     clientID,
		"OIDC_CLIENT_SECRET": "shhh",
		"APP_BASE_URL":       "http://localhost:8787", // redirect URI base; IdP doesn't validate it here
	})

	idp.sub, idp.email, idp.emailVerified, idp.name = "subject-1", "sso-dev@example.com", true, "SSO Dev"

	c := newClient(t)
	if loc := oidcLogin(t, c, ts.URL); loc != "/" {
		t.Fatalf("post-login redirect = %q, want /", loc)
	}
	u, code := meUser(t, c, ts.URL)
	if code != http.StatusOK {
		t.Fatalf("me after oidc login: %d", code)
	}
	if u.Email != "sso-dev@example.com" || !u.Roles.Developer || u.Roles.Admin {
		t.Fatalf("oidc-created user wrong: %+v", u)
	}
	firstID := u.ID

	// second login with the same subject reuses the account
	c2 := newClient(t)
	oidcLogin(t, c2, ts.URL)
	u2, _ := meUser(t, c2, ts.URL)
	if u2.ID != firstID {
		t.Fatalf("second oidc login created a new user: %s vs %s", u2.ID, firstID)
	}
}

func TestOIDCLinksVerifiedEmailToLocalAccount(t *testing.T) {
	const clientID = "bountyboard-test"
	idp := newFakeIdP(t, clientID)
	ts, _, _, _ := newAuthStack(t, map[string]string{
		"OIDC_ISSUER_URL":    idp.srv.URL,
		"OIDC_CLIENT_ID":     clientID,
		"OIDC_CLIENT_SECRET": "shhh",
	})

	// existing local account
	c := newClient(t)
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "linked@example.com", "name": "Linked", "password": "password123"}, "")
	var reg struct {
		User domain.User `json:"user"`
	}
	bodyJSON(t, resp, &reg)

	idp.sub, idp.email, idp.emailVerified, idp.name = "subject-link", "Linked@Example.com", true, "Linked"
	c2 := newClient(t)
	if loc := oidcLogin(t, c2, ts.URL); loc != "/" {
		t.Fatalf("link flow redirect = %q", loc)
	}
	u, _ := meUser(t, c2, ts.URL)
	if u.ID != reg.User.ID {
		t.Fatalf("oidc login did not link: got user %s, want %s", u.ID, reg.User.ID)
	}

	// local password still works after linking
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "linked@example.com", "password": "password123"}, "")
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("local login after link: %d", resp.StatusCode)
	}
	resp.Body.Close()
}

func TestOIDCUnverifiedEmailDoesNotLink(t *testing.T) {
	const clientID = "bountyboard-test"
	idp := newFakeIdP(t, clientID)
	ts, _, _, _ := newAuthStack(t, map[string]string{
		"OIDC_ISSUER_URL":    idp.srv.URL,
		"OIDC_CLIENT_ID":     clientID,
		"OIDC_CLIENT_SECRET": "shhh",
	})

	c := newClient(t)
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "victim@example.com", "name": "V", "password": "password123"}, "")
	resp.Body.Close()

	idp.sub, idp.email, idp.emailVerified = "attacker", "victim@example.com", false
	c2 := newClient(t)
	loc := oidcLogin(t, c2, ts.URL)
	if !strings.Contains(loc, "error=oidc_email_unverified") {
		t.Fatalf("unverified email link must be refused, redirect = %q", loc)
	}
	if _, code := meUser(t, c2, ts.URL); code != http.StatusUnauthorized {
		t.Fatalf("attacker must not get a session, me = %d", code)
	}
}

func TestOIDCStateMismatchRejected(t *testing.T) {
	const clientID = "bountyboard-test"
	idp := newFakeIdP(t, clientID)
	ts, _, _, _ := newAuthStack(t, map[string]string{
		"OIDC_ISSUER_URL":    idp.srv.URL,
		"OIDC_CLIENT_ID":     clientID,
		"OIDC_CLIENT_SECRET": "shhh",
	})
	idp.sub, idp.email, idp.emailVerified = "s", "s@example.com", true

	c := newClient(t)
	resp, err := c.Get(ts.URL + "/api/v1/auth/oidc/login")
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()

	resp, err = c.Get(ts.URL + "/api/v1/auth/oidc/callback?code=fake-code&state=forged-state")
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if loc := resp.Header.Get("Location"); !strings.Contains(loc, "error=oidc_state_mismatch") {
		t.Fatalf("forged state redirect = %q", loc)
	}
}

func TestOIDCDisabledByDefault(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)
	resp, err := http.Get(ts.URL + "/api/v1/auth/oidc/login")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("oidc login without config: %d, want 404", resp.StatusCode)
	}
}
