//go:build integration

package httpx

import (
	"fmt"
	"net/http"
	"testing"

	"go.mongodb.org/mongo-driver/v2/bson"

	"bountyboard/internal/domain"
	"bountyboard/internal/store"
)

// promote grants roles to a user directly in the store.
func promote(t *testing.T, st *store.Store, userID string, roles map[string]bool) {
	t.Helper()
	set := bson.M{}
	for role, v := range roles {
		set["roles."+role] = v
	}
	if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
		bson.M{"_id": userID}, bson.M{"$set": set}); err != nil {
		t.Fatal(err)
	}
}

// adminClient registers + promotes an admin and returns a logged-in client.
func adminClient(t *testing.T, ts string, st *store.Store, email string) (*http.Client, string, domain.User) {
	t.Helper()
	c := newClient(t)
	u := registerUser(t, ts, c, email, "Admin "+email)
	promote(t, st, u.ID, map[string]bool{"admin": true})
	return c, csrfFrom(t, c, ts), u
}

func TestAdminRBACDenied(t *testing.T) {
	ts, _, _, _ := newAuthStack(t, nil)
	c := newClient(t)
	registerUser(t, ts.URL, c, "plebeian@example.com", "Plebeian")
	csrf := csrfFrom(t, c, ts.URL)

	// developer is denied on every admin route
	for _, probe := range []struct{ method, path string }{
		{"GET", "/api/v1/admin/users"},
		{"GET", "/api/v1/admin/customers"},
		{"GET", "/api/v1/admin/settings"},
		{"GET", "/api/v1/admin/audit-log"},
		{"GET", "/api/v1/admin/service-status"},
	} {
		req, _ := http.NewRequest(probe.method, ts.URL+probe.path, nil)
		req.Header.Set(csrfHeader, csrf)
		resp, err := c.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		resp.Body.Close()
		if resp.StatusCode != http.StatusForbidden {
			t.Errorf("%s %s as developer: %d, want 403", probe.method, probe.path, resp.StatusCode)
		}
	}
}

func TestAdminCustomerLifecycle(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c, csrf, _ := adminClient(t, ts.URL, st, "boss@example.com")

	// a consultant to assign
	cc := newClient(t)
	consultant := registerUser(t, ts.URL, cc, "cons@example.com", "Cons")
	promote(t, st, consultant.ID, map[string]bool{"consultant": true})

	// create demo customer
	resp := postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
		"name": "ACME Demo", "type": "demo", "projectKey": "ACME",
		"pollIntervalSec": 15, "defaultBudget": 2000,
		"consultantIds": []string{consultant.ID},
		"credentials":   map[string]string{},
	}, csrf)
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("create customer: %d", resp.StatusCode)
	}
	var created struct {
		Customer struct {
			ID        string `json:"id"`
			Version   int64  `json:"version"`
			Ticketing struct {
				HasCredentials bool `json:"hasCredentials"`
			} `json:"ticketing"`
		} `json:"customer"`
	}
	bodyJSON(t, resp, &created)
	id := created.Customer.ID

	// jira customer requires credentials
	resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
		"name": "Broken Jira", "type": "jira", "credentials": map[string]string{"email": "x@y.z"},
	}, csrf)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("jira without token: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// duplicate name
	resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
		"name": "ACME Demo", "type": "demo",
	}, csrf)
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("duplicate name: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// stored credentials round-trip through test-connection (demo always ok)
	resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id+"/test-connection", map[string]any{}, csrf)
	var test struct {
		OK bool `json:"ok"`
	}
	bodyJSON(t, resp, &test)
	if !test.OK {
		t.Fatal("demo test-connection should pass")
	}

	// unsaved-credentials test endpoint (jira shape validation)
	resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/test-connection", map[string]any{
		"type": "jira", "baseUrl": "https://nope.invalid", "credentials": map[string]string{},
	}, csrf)
	bodyJSON(t, resp, &test)
	if test.OK {
		t.Fatal("jira with empty credentials must fail the test")
	}

	// patch: rename + archive
	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{
		"name": "ACME Renamed", "archived": true, "version": created.Customer.Version,
	}, csrf)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("patch customer: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// stale version conflicts
	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{
		"name": "Stale", "version": created.Customer.Version,
	}, csrf)
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("stale customer patch: %d", resp.StatusCode)
	}
	resp.Body.Close()

	// delete works while no tasks exist
	req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/customers/"+id, nil)
	req.Header.Set(csrfHeader, csrf)
	dResp, err := c.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	dResp.Body.Close()
	if dResp.StatusCode != http.StatusNoContent {
		t.Fatalf("delete customer: %d", dResp.StatusCode)
	}

	// audit log recorded the mutations
	aResp, err := c.Get(ts.URL + "/api/v1/admin/audit-log")
	if err != nil {
		t.Fatal(err)
	}
	var audit struct {
		Entries []store.AuditEntry `json:"entries"`
	}
	bodyJSON(t, aResp, &audit)
	actions := map[string]bool{}
	for _, e := range audit.Entries {
		actions[e.Action] = true
	}
	for _, want := range []string{"customer.create", "customer.update", "customer.delete"} {
		if !actions[want] {
			t.Errorf("audit log missing action %q (have %v)", want, actions)
		}
	}
}

func TestAdminUserManagement(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c, csrf, admin := adminClient(t, ts.URL, st, "root2@example.com")

	tc := newClient(t)
	target := registerUser(t, ts.URL, tc, "target@example.com", "Target")

	// search finds the user
	resp, err := c.Get(ts.URL + "/api/v1/admin/users?q=target")
	if err != nil {
		t.Fatal(err)
	}
	var list struct {
		Users []domain.User `json:"users"`
	}
	bodyJSON(t, resp, &list)
	if len(list.Users) != 1 || list.Users[0].ID != target.ID {
		t.Fatalf("search: %+v", list.Users)
	}

	// grant consultant role
	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{
		"roles": map[string]bool{"admin": false, "consultant": true, "developer": true},
	}, csrf)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("grant role: %d", resp.StatusCode)
	}
	var patched struct {
		User domain.User `json:"user"`
	}
	bodyJSON(t, resp, &patched)
	if !patched.User.Roles.Consultant {
		t.Fatal("consultant role not granted")
	}

	// force reset marks mustChange
	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"forceReset": true}, csrf)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("force reset: %d", resp.StatusCode)
	}
	resp.Body.Close()
	fresh, err := st.UserByID(t.Context(), target.ID)
	if err != nil {
		t.Fatal(err)
	}
	if !fresh.MustChangePassword() {
		t.Fatal("forceReset did not set mustChange")
	}

	// disabling kicks the user's session
	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"disabled": true}, csrf)
	resp.Body.Close()
	meResp, _ := tc.Get(ts.URL + "/api/v1/auth/me")
	meResp.Body.Close()
	if meResp.StatusCode == http.StatusOK {
		t.Fatal("disabled user still has a working session")
	}

	// self-demotion and self-disable are blocked
	for i, body := range []map[string]any{
		{"roles": map[string]bool{"admin": false, "consultant": false, "developer": true}},
		{"disabled": true},
	} {
		resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+admin.ID, body, csrf)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("self-mutation %d: %d, want 400", i, resp.StatusCode)
		}
		resp.Body.Close()
	}

	// delete target
	req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/users/"+target.ID, nil)
	req.Header.Set(csrfHeader, csrf)
	dResp, err := c.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	dResp.Body.Close()
	if dResp.StatusCode != http.StatusNoContent {
		t.Fatalf("delete user: %d", dResp.StatusCode)
	}
}

func TestAdminSettings(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c, csrf, _ := adminClient(t, ts.URL, st, "settings@example.com")

	resp, err := c.Get(ts.URL + "/api/v1/admin/settings")
	if err != nil {
		t.Fatal(err)
	}
	var got struct {
		Settings domain.AppSettings `json:"settings"`
	}
	bodyJSON(t, resp, &got)
	if got.Settings.DefaultPollIntervalSec != 60 {
		t.Fatalf("default poll = %d", got.Settings.DefaultPollIntervalSec)
	}

	resp = patchJSON(t, c, ts.URL+"/api/v1/admin/settings", map[string]any{
		"brandingName": "ACME Bounties", "defaultPollIntervalSec": 30,
		"hideCompetingClaims": true,
	}, csrf)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("patch settings: %d", resp.StatusCode)
	}
	bodyJSON(t, resp, &got)
	if got.Settings.BrandingName != "ACME Bounties" || got.Settings.DefaultPollIntervalSec != 30 ||
		!got.Settings.HideCompetingClaims {
		t.Fatalf("settings after patch: %+v", got.Settings)
	}
}

func TestAdminServiceStatusShape(t *testing.T) {
	ts, _, st, _ := newAuthStack(t, nil)
	c, _, _ := adminClient(t, ts.URL, st, fmt.Sprintf("status-%d@example.com", 1))

	resp, err := c.Get(ts.URL + "/api/v1/admin/service-status")
	if err != nil {
		t.Fatal(err)
	}
	var out map[string]any
	bodyJSON(t, resp, &out)
	for _, key := range []string{"mongo", "atomizer", "workPerformer", "syncWorkers", "jobs"} {
		if _, ok := out[key]; !ok {
			t.Errorf("service-status missing %q", key)
		}
	}
	mongo := out["mongo"].(map[string]any)
	if mongo["healthy"] != true {
		t.Error("mongo should be healthy in tests")
	}
}
