//go:build integration

package httpx

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"os"
	"testing"
	"time"

	"bountyboard/internal/auth"
	"bountyboard/internal/config"
	"bountyboard/internal/domain"
	"bountyboard/internal/files"
	"bountyboard/internal/metrics"
	"bountyboard/internal/store"
	"bountyboard/internal/testutil"
)

// newAuthStack builds a full server backed by a per-run Mongo database.
// extraEnv is applied before config.Load.
func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *Server, *store.Store, *config.Config) {
	t.Helper()
	db := testutil.MongoDB(t)
	if err := store.EnsureIndexes(t.Context(), db); err != nil {
		t.Fatal(err)
	}
	st := &store.Store{Client: db.Client(), DB: db}

	t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
	t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
	for k, v := range extraEnv {
		t.Setenv(k, v)
	}
	cfg, err := config.Load()
	if err != nil {
		t.Fatal(err)
	}
	logOut := io.Writer(io.Discard)
	if os.Getenv("TEST_LOG") == "1" {
		logOut = os.Stderr
	}
	srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st,
		files.NewStore(db, cfg.MaxUploadMB))
	ts := httptest.NewServer(srv.Handler())
	t.Cleanup(ts.Close)
	return ts, srv, st, cfg
}

// client returns an http client with a cookie jar that does not follow
// redirects (so OIDC hops can be inspected).
func newClient(t *testing.T) *http.Client {
	t.Helper()
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatal(err)
	}
	return &http.Client{
		Jar:           jar,
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}
}

func postJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response {
	t.Helper()
	b, err := json.Marshal(body)
	if err != nil {
		t.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(b))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	if csrf != "" {
		req.Header.Set(csrfHeader, csrf)
	}
	resp, err := c.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	return resp
}

func bodyJSON(t *testing.T, resp *http.Response, dst any) {
	t.Helper()
	defer resp.Body.Close()
	if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
		t.Fatalf("decode response: %v", err)
	}
}

func csrfFrom(t *testing.T, c *http.Client, baseURL string) string {
	t.Helper()
	u, _ := url.Parse(baseURL)
	for _, ck := range c.Jar.Cookies(u) {
		if ck.Name == csrfCookie {
			return ck.Value
		}
	}
	return ""
}

func errCode(t *testing.T, resp *http.Response) string {
	t.Helper()
	var env struct {
		Error struct {
			Code string `json:"code"`
		} `json:"error"`
	}
	bodyJSON(t, resp, &env)
	return env.Error.Code
}

func TestRegisterLoginLogoutFlow(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)
	c := newClient(t)

	// invalid inputs
	for _, tc := range []struct {
		body map[string]string
		want string
	}{
		{map[string]string{"email": "not-an-email", "name": "X", "password": "longenough"}, "invalid_email"},
		{map[string]string{"email": "a@b.co", "name": "", "password": "longenough"}, "invalid_name"},
		{map[string]string{"email": "a@b.co", "name": "X", "password": "short"}, "weak_password"},
	} {
		resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", tc.body, "")
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("register %v: code %d", tc.body, resp.StatusCode)
		}
		if got := errCode(t, resp); got != tc.want {
			t.Errorf("error code %q, want %q", got, tc.want)
		}
	}

	// successful registration creates a developer and a session
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "Dev@Example.com", "name": "Dev One", "password": "password123"}, "")
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("register: %d", resp.StatusCode)
	}
	var reg struct {
		User domain.User `json:"user"`
	}
	bodyJSON(t, resp, &reg)
	if reg.User.Email != "dev@example.com" {
		t.Errorf("email not normalized: %q", reg.User.Email)
	}
	if !reg.User.Roles.Developer || reg.User.Roles.Admin || reg.User.Roles.Consultant {
		t.Errorf("self-registration roles wrong: %+v", reg.User.Roles)
	}

	// session works
	meResp, err := c.Get(ts.URL + "/api/v1/auth/me")
	if err != nil || meResp.StatusCode != http.StatusOK {
		t.Fatalf("me after register: %v %d", err, meResp.StatusCode)
	}
	meResp.Body.Close()

	// duplicate email
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "dev@example.com", "name": "Imposter", "password": "password123"}, "")
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("duplicate register: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// logout requires CSRF
	resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, "")
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("logout without csrf: %d", resp.StatusCode)
	}
	resp.Body.Close()

	csrf := csrfFrom(t, c, ts.URL)
	if csrf == "" {
		t.Fatal("csrf cookie not set")
	}
	resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, csrf)
	if resp.StatusCode != http.StatusNoContent {
		t.Fatalf("logout: %d", resp.StatusCode)
	}
	resp.Body.Close()

	meResp, err = c.Get(ts.URL + "/api/v1/auth/me")
	if err != nil || meResp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("me after logout: %v %d", err, meResp.StatusCode)
	}
	meResp.Body.Close()

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

	// wrong password and unknown email are indistinguishable 401s
	for _, body := range []map[string]string{
		{"email": "dev@example.com", "password": "wrong-password"},
		{"email": "ghost@example.com", "password": "password123"},
	} {
		resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", body, "")
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("login %v: %d", body, resp.StatusCode)
		}
		if got := errCode(t, resp); got != "invalid_credentials" {
			t.Errorf("error code %q", got)
		}
	}
}

func TestLoginRateLimit(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)
	for i := 0; i < 10; i++ {
		resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
			map[string]string{"email": "victim@example.com", "password": "guess"}, "")
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("attempt %d: %d", i+1, resp.StatusCode)
		}
		resp.Body.Close()
	}
	resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "victim@example.com", "password": "guess"}, "")
	if resp.StatusCode != http.StatusTooManyRequests {
		t.Fatalf("11th attempt: %d, want 429", resp.StatusCode)
	}
	resp.Body.Close()

	// other accounts from the same IP are unaffected (key includes email)
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "other@example.com", "password": "guess"}, "")
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("different email after limit: %d", resp.StatusCode)
	}
	resp.Body.Close()
}

func TestDisabledAccount(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c := newClient(t)
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "d@example.com", "name": "D", "password": "password123"}, "")
	var reg struct {
		User domain.User `json:"user"`
	}
	bodyJSON(t, resp, &reg)

	// disable directly in the store
	if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
		map[string]any{"_id": reg.User.ID}, map[string]any{"$set": map[string]any{"disabled": true}}); err != nil {
		t.Fatal(err)
	}

	// existing session is revoked on next use
	meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
	if meResp.StatusCode != http.StatusForbidden {
		t.Fatalf("me as disabled: %d", meResp.StatusCode)
	}
	meResp.Body.Close()

	// fresh login rejected
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "d@example.com", "password": "password123"}, "")
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("login as disabled: %d", resp.StatusCode)
	}
	if got := errCode(t, resp); got != "account_disabled" {
		t.Errorf("error code %q", got)
	}
}

func TestBootstrapAdminAndForcedPasswordChange(t *testing.T) {
	ts, srv, st, cfg := newAuthStack(t, map[string]string{
		"ADMIN_EMAIL":            "root@example.com",
		"ADMIN_INITIAL_PASSWORD": "initial-password",
	})
	if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
		t.Fatal(err)
	}
	// idempotent
	if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
		t.Fatal(err)
	}
	if n, _ := st.CountUsers(t.Context()); n != 1 {
		t.Fatalf("user count = %d, want 1", n)
	}

	c := newClient(t)
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("admin login: %d", resp.StatusCode)
	}
	var login struct {
		User               domain.User `json:"user"`
		MustChangePassword bool        `json:"mustChangePassword"`
	}
	bodyJSON(t, resp, &login)
	if !login.User.Roles.Admin || !login.MustChangePassword {
		t.Fatalf("bootstrap admin login: %+v must=%v", login.User.Roles, login.MustChangePassword)
	}

	// while mustChange is set, non-allowlisted endpoints are blocked
	csrf := csrfFrom(t, c, ts.URL)
	probe := httptest.NewServer(srv.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})))
	defer probe.Close()
	req, _ := http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
	u, _ := url.Parse(ts.URL)
	for _, ck := range c.Jar.Cookies(u) {
		req.AddCookie(ck)
	}
	pResp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	if pResp.StatusCode != http.StatusForbidden {
		t.Fatalf("protected endpoint under mustChange: %d, want 403", pResp.StatusCode)
	}
	pResp.Body.Close()

	// wrong current password
	resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
		map[string]string{"currentPassword": "nope", "newPassword": "fresh-password-1"}, csrf)
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("change with wrong current: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// correct change clears the flag
	resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
		map[string]string{"currentPassword": "initial-password", "newPassword": "fresh-password-1"}, csrf)
	if resp.StatusCode != http.StatusNoContent {
		t.Fatalf("change password: %d", resp.StatusCode)
	}
	resp.Body.Close()

	req, _ = http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
	for _, ck := range c.Jar.Cookies(u) {
		req.AddCookie(ck)
	}
	pResp, err = http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	if pResp.StatusCode != http.StatusOK {
		t.Fatalf("protected endpoint after change: %d", pResp.StatusCode)
	}
	pResp.Body.Close()

	// old password dead, new one works
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("old password still works: %d", resp.StatusCode)
	}
	resp.Body.Close()
	resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "root@example.com", "password": "fresh-password-1"}, "")
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("new password rejected: %d", resp.StatusCode)
	}
	resp.Body.Close()
}

func TestPasswordChangeRevokesOtherSessions(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)

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

	resp = postJSON(t, c1, ts.URL+"/api/v1/auth/change-password",
		map[string]string{"currentPassword": "password123", "newPassword": "password456"}, csrfFrom(t, c1, ts.URL))
	if resp.StatusCode != http.StatusNoContent {
		t.Fatalf("change: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// c1 (the changer) still works, c2 is revoked
	r1, _ := c1.Get(ts.URL + "/api/v1/auth/me")
	if r1.StatusCode != http.StatusOK {
		t.Errorf("changer session: %d", r1.StatusCode)
	}
	r1.Body.Close()
	r2, _ := c2.Get(ts.URL + "/api/v1/auth/me")
	if r2.StatusCode != http.StatusUnauthorized {
		t.Errorf("other session after password change: %d, want 401", r2.StatusCode)
	}
	r2.Body.Close()
}

func TestExpiredSessionRejected(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c := newClient(t)
	resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "e@example.com", "name": "E", "password": "password123"}, "")
	resp.Body.Close()

	// force-expire every session for the user
	if _, err := st.DB.Collection("sessions").UpdateMany(t.Context(),
		map[string]any{}, map[string]any{"$set": map[string]any{"expiresAt": time.Now().Add(-time.Minute)}}); err != nil {
		t.Fatal(err)
	}
	meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
	if meResp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expired session: %d", meResp.StatusCode)
	}
	meResp.Body.Close()
}

func TestLogoutAll(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)
	c1, c2 := newClient(t), newClient(t)
	resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register",
		map[string]string{"email": "la@example.com", "name": "LA", "password": "password123"}, "")
	resp.Body.Close()
	resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "la@example.com", "password": "password123"}, "")
	resp.Body.Close()

	resp = postJSON(t, c1, ts.URL+"/api/v1/auth/logout-all", map[string]string{}, csrfFrom(t, c1, ts.URL))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("logout-all: %d", resp.StatusCode)
	}
	var out struct {
		Revoked int `json:"revoked"`
	}
	bodyJSON(t, resp, &out)
	if out.Revoked != 2 {
		t.Errorf("revoked = %d, want 2", out.Revoked)
	}
	for i, c := range []*http.Client{c1, c2} {
		r, _ := c.Get(ts.URL + "/api/v1/auth/me")
		if r.StatusCode != http.StatusUnauthorized {
			t.Errorf("client %d after logout-all: %d", i+1, r.StatusCode)
		}
		r.Body.Close()
	}
}

func TestOIDCOnlyAccountCannotPasswordLogin(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	if err := st.CreateUser(t.Context(), &domain.User{
		ID: "01JOIDCONLY0000000000000US", Email: "sso@example.com", Name: "SSO",
		Roles: domain.Roles{Developer: true},
		Auth:  domain.Auth{OIDC: &domain.OIDCAuth{Issuer: "https://idp", Subject: "x"}},
	}); err != nil {
		t.Fatal(err)
	}
	resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
		map[string]string{"email": "sso@example.com", "password": "whatever123"}, "")
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("oidc-only password login: %d", resp.StatusCode)
	}
	resp.Body.Close()
}
