package handlers

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"

	"chat-switchboard/config"
	authpkg "chat-switchboard/auth"
	"chat-switchboard/database"
	"chat-switchboard/middleware"
	"chat-switchboard/models"
	"chat-switchboard/roles"
	"chat-switchboard/store"
	postgres "chat-switchboard/store/postgres"
	sqlite "chat-switchboard/store/sqlite"
	"chat-switchboard/treepath"
)

// ── Test Harness ────────────────────────────

// dialectSQL converts Postgres-style $N placeholders to ? for SQLite,
// strips ::jsonb casts, and converts boolean literals to integers.
// Allows raw SQL in tests to work on both backends.
func dialectSQL(q string) string {
	if !database.IsSQLite() {
		return q
	}
	result := q
	// Replace high-to-low to avoid $1 matching inside $10
	for i := 20; i >= 1; i-- {
		result = strings.ReplaceAll(result, fmt.Sprintf("$%d", i), "?")
	}
	result = strings.ReplaceAll(result, "::jsonb", "")
	result = strings.ReplaceAll(result, "::text", "")
	// Boolean literals: true/false → 1/0 (only bare keywords, not string 'true')
	result = strings.ReplaceAll(result, "= true", "= 1")
	result = strings.ReplaceAll(result, "= false", "= 0")
	result = strings.ReplaceAll(result, ", true)", ", 1)")
	result = strings.ReplaceAll(result, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
	// Time functions
	result = strings.ReplaceAll(result, "NOW()", "datetime('now')")
	// NULL sort
	result = strings.ReplaceAll(result, "NULLS LAST", "")
	return result
}

// seedID returns a new UUID for use in test seed data.
func seedID() string {
	return uuid.New().String()
}

// seedInsertReturningID executes an INSERT with RETURNING id on Postgres,
// or injects a generated UUID id on SQLite and returns it.
func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string {
	t.Helper()
	if !database.IsSQLite() {
		var id string
		err := database.TestDB.QueryRow(query, args...).Scan(&id)
		if err != nil {
			t.Fatalf("seedInsertReturningID: %v", err)
		}
		return id
	}
	id := seedID()
	q := dialectSQL(query)
	// Remove RETURNING clause
	if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
		q = strings.TrimSpace(q[:idx])
	}
	// Inject id column
	q = database.InjectIDForTest(q)
	newArgs := append([]interface{}{id}, args...)
	_, err := database.TestDB.Exec(q, newArgs...)
	if err != nil {
		t.Fatalf("seedInsertReturningID: %v", err)
	}
	return id
}

// seedExec executes an INSERT via dialectSQL; on SQLite it also injects
// a generated id column and value (first arg) so that TEXT PRIMARY KEY
// tables that lack a DEFAULT get a proper UUID.
func seedExec(t *testing.T, query string, args ...interface{}) {
	t.Helper()
	q := dialectSQL(query)
	if database.IsSQLite() {
		q = database.InjectIDForTest(q)
		args = append([]interface{}{seedID()}, args...)
	}
	_, err := database.TestDB.Exec(q, args...)
	if err != nil {
		t.Fatalf("seedExec: %v\n  query: %s", err, q)
	}
}

const testJWTSecret = "test-secret-key-for-integration-tests"

type testHarness struct {
	router *gin.Engine
	t      *testing.T
}

// setupHarness creates a full API router backed by the test database.
// Call database.RequireTestDB(t) + database.TruncateAll(t) before using.
func setupHarness(t *testing.T) *testHarness {
	t.Helper()
	database.RequireTestDB(t)
	database.TruncateAll(t)

	cfg := &config.Config{
		JWTSecret: testJWTSecret,
		BasePath:  "",
	}

	var stores store.Stores
	if database.IsSQLite() {
		stores = sqlite.NewStores(database.TestDB)
	} else {
		stores = postgres.NewStores(database.TestDB)
	}
	treepath.Stores = &stores
	userCache := middleware.NewUserStatusCache()

	// Roles resolver (nil vault — test-fire won't work, but CRUD will)
	roleResolver := roles.NewResolver(stores, nil)

	r := gin.New()
	api := r.Group("/api/v1")

	// Auth (unprotected)
	auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
	authGroup := api.Group("/auth")
	authGroup.POST("/register", auth.Register)
	authGroup.POST("/login", auth.Login)

	// Public settings
	adm := NewAdminHandler(stores, nil, nil, nil)
	api.GET("/settings/public", adm.PublicSettings)

	// Protected routes
	protected := api.Group("")
	protected.Use(middleware.Auth(cfg, stores.Users, userCache))

	// Models
	models := NewModelHandler(stores)
	protected.GET("/models/enabled", models.ListEnabledModels)

	// Model prefs
	modelPrefs := NewModelPrefsHandler(stores)
	protected.GET("/models/preferences", modelPrefs.GetPreferences)
	protected.PUT("/models/preferences", modelPrefs.SetPreference)
	protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)

	// User providers
	provCfg := NewProviderConfigHandler(stores, nil)
	protected.GET("/api-configs", provCfg.ListConfigs)
	protected.POST("/api-configs", provCfg.CreateConfig)
	protected.GET("/api-configs/:id", provCfg.GetConfig)
	protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
	protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
	protected.GET("/api-configs/:id/models", provCfg.ListModels)
	protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)

	// Team self-service (same route group as production)
	teams := NewTeamHandler(stores, nil)
	protected.GET("/teams/mine", teams.MyTeams)

	teamScoped := protected.Group("/teams/:teamId")
	teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams))
	{
		// Team members (team admin self-service)
		teamScoped.GET("/members", teams.ListMembers)
		teamScoped.POST("/members", teams.AddMember)
		teamScoped.PUT("/members/:memberId", teams.UpdateMember)
		teamScoped.DELETE("/members/:memberId", teams.RemoveMember)

		// Team providers
		teamScoped.GET("/providers", teams.ListTeamProviders)
		teamScoped.POST("/providers", teams.CreateTeamProvider)
		teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
		teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
		teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)

		// Team audit
		teamScoped.GET("/audit", teams.ListTeamAuditLog)
		teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)

		// Team groups
		teamGroupH := NewGroupHandler(stores)
		teamScoped.GET("/groups", teamGroupH.ListTeamGroups)

		// Team usage
		teamUsage := NewUsageHandler(stores)
		teamScoped.GET("/usage", teamUsage.TeamUsage)

		// Team roles
		teamRoles := NewRolesHandler(stores, roleResolver)
		teamScoped.GET("/roles", teamRoles.ListTeamRoles)
		teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
		teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)

		// Team tasks — admin CRUD (v0.28.0-audit)
		teamTaskH := NewTaskHandler(stores)
		teamScoped.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
		teamScoped.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Update)
		teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete)
		teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
		teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)

		// Team personas (v0.28.0-audit)
		teamPersonas := NewPersonaHandler(stores)
		teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
		teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
		teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
		teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
		teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs)
		teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs)
		teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants)
		teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants)
		teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar)
		teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar)
	}

	// Team task viewing for all members (v0.28.0-audit)
	// NOTE: Uses RequireTeamMember which has raw $1 placeholders — admin
	// users bypass the DB query so these tests work on both dialects when
	// the caller is an admin.  Non-admin member tests require Postgres.
	teamMemberRoutes := protected.Group("/teams/:teamId")
	teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
	{
		teamMemberTaskH := NewTaskHandler(stores)
		teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
		teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
	}

	// User usage
	usage := NewUsageHandler(stores)
	protected.GET("/usage", usage.PersonalUsage)

	// Profile permissions (v0.37.1)
	permH := NewProfilePermissionsHandler(stores)
	protected.GET("/profile/permissions", permH.GetMyPermissions)

	// Boot payload (v0.37.15)
	bootH := NewProfileBootstrapHandler(stores)
	protected.GET("/profile/bootstrap", bootH.GetBootstrap)

	// Profile / Settings
	settings := NewSettingsHandler(stores, nil)
	protected.GET("/profile", settings.GetProfile)
	protected.PUT("/profile", settings.UpdateProfile)
	protected.POST("/profile/password", settings.ChangePassword)
	protected.POST("/profile/avatar", settings.UploadAvatar)
	protected.DELETE("/profile/avatar", settings.DeleteAvatar)
	protected.GET("/settings", settings.GetSettings)
	protected.PUT("/settings", settings.UpdateSettings)

	// Personas
	personas := NewPersonaHandler(stores)
	protected.GET("/personas", personas.ListUserPersonas)
	protected.POST("/personas", middleware.RequirePermission(authpkg.PermPersonaCreate, stores), personas.CreateUserPersona)
	protected.PUT("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.UpdateUserPersona)
	protected.DELETE("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.DeleteUserPersona)
	protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
	protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
	protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs)   // v0.17.0
	protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs)   // v0.17.0
	protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
	protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0

	// Persona Groups
	pgH := NewPersonaGroupHandler(stores)
	protected.GET("/persona-groups", pgH.List)
	protected.POST("/persona-groups", pgH.Create)
	protected.GET("/persona-groups/:id", pgH.Get)
	protected.PUT("/persona-groups/:id", pgH.Update)
	protected.DELETE("/persona-groups/:id", pgH.Delete)
	protected.POST("/persona-groups/:id/members", pgH.AddMember)
	protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)

	// Notes
	notes := NewNoteHandler(stores)
	protected.GET("/notes", notes.List)
	protected.POST("/notes", notes.Create)
	protected.GET("/notes/:id", notes.Get)
	protected.PUT("/notes/:id", notes.Update)
	protected.DELETE("/notes/:id", notes.Delete)

	// Channels
	channels := NewChannelHandler(stores)
	protected.GET("/channels", channels.ListChannels)
	protected.POST("/channels", middleware.RequirePermission(authpkg.PermChannelCreate, stores), channels.CreateChannel)
	protected.GET("/channels/:id", channels.GetChannel)
	protected.DELETE("/channels/:id", channels.DeleteChannel)

	// Channel participants
	partH := NewParticipantHandler(stores)
	protected.GET("/channels/:id/participants", partH.List)
	protected.POST("/channels/:id/participants", middleware.RequirePermission(authpkg.PermChannelInvite, stores), partH.Add)

	// Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503)
	kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil)
	protected.POST("/knowledge-bases", middleware.RequirePermission(authpkg.PermKBCreate, stores), kbH.CreateKB)
	protected.GET("/knowledge-bases", kbH.ListKBs)
	protected.GET("/knowledge-bases/:id", kbH.GetKB)
	protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UpdateKB)
	protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteKB)
	protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UploadDocument)
	protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
	protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
	protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteDocument)
	protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(authpkg.PermKBRead, stores), kbH.SearchKB)
	protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.RebuildKB)
	protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
	protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
	protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
	protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0

	// Files (nil storage = upload returns 503, but metadata works)
	fileH := NewFileHandler(stores, nil, nil)
	protected.POST("/channels/:id/files", fileH.Upload)
	protected.GET("/channels/:id/files", fileH.ListByChannel)
	protected.GET("/files/:id", fileH.GetMetadata)
	protected.GET("/files/:id/download", fileH.Download)
	protected.DELETE("/files/:id", fileH.Delete)

	// Completions
	completions := NewCompletionHandler(nil, stores, nil, nil, nil)
	protected.POST("/chat/completions", middleware.RequirePermission(authpkg.PermModelUse, stores), completions.Complete)

	// Messages
	msgs := NewMessageHandler(nil, stores, nil, nil)
	protected.GET("/channels/:id/messages", msgs.ListMessages)
	protected.POST("/channels/:id/messages", msgs.CreateMessage)
	protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
	protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
	protected.GET("/channels/:id/path", msgs.GetActivePath)

	// Workflows (v0.37.1 — perm enforcement)
	wfH := NewWorkflowHandler(stores)
	protected.POST("/workflows", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Create)
	protected.GET("/workflows", wfH.List)
	protected.PATCH("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Update)
	protected.DELETE("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Delete)

	// Tasks (v0.28.0)
	taskH := NewTaskHandler(stores)
	protected.GET("/tasks", taskH.ListMine)

	// Memory (v0.18.0, audit v0.28.0)
	memH := NewMemoryHandler(stores)
	protected.GET("/memories", memH.ListMyMemories)
	protected.PUT("/memories/:id", memH.UpdateMemory)
	protected.DELETE("/memories/:id", memH.DeleteMemory)
	protected.POST("/memories/:id/approve", memH.ApproveMemory)
	protected.POST("/memories/:id/reject", memH.RejectMemory)
	protected.GET("/memories/count", memH.MemoryCount)

	protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create)
	protected.GET("/tasks/:id", taskH.Get)
	protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update)
	protected.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Delete)
	protected.GET("/tasks/:id/runs", taskH.ListRuns)
	protected.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.RunNow)
	protected.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.KillRun)

	// Webhook trigger (unauthenticated, token-based)
	triggerH := NewTriggerHandler(stores)
	api.POST("/hooks/t/:token", triggerH.Handle)

	// Admin routes
	admin := api.Group("/admin")
	admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
	admin.GET("/users", adm.ListUsers)
	admin.POST("/users", adm.CreateUser)
	admin.PUT("/users/:id/active", adm.ToggleUserActive)
	admin.GET("/settings", adm.ListGlobalSettings)
	admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
	admin.GET("/configs", adm.ListGlobalConfigs)
	admin.POST("/configs", adm.CreateGlobalConfig)
	admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
	admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
	admin.GET("/models", adm.ListModelConfigs)
	admin.PUT("/models/:id", adm.UpdateModelConfig)
	admin.PUT("/models/bulk", adm.BulkUpdateModels)
	admin.POST("/models/fetch", adm.FetchModels)
	admin.GET("/teams", teams.ListTeams)
	admin.POST("/teams", teams.CreateTeam)
	admin.GET("/teams/:id", teams.GetTeam)
	admin.PUT("/teams/:id", teams.UpdateTeam)
	admin.DELETE("/teams/:id", teams.DeleteTeam)
	admin.GET("/teams/:id/members", teams.ListMembers)
	admin.POST("/teams/:id/members", teams.AddMember)
	admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember)
	admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
	admin.GET("/personas", personas.ListAdminPersonas)
	admin.POST("/personas", personas.CreateAdminPersona)
	admin.PUT("/personas/:id", personas.UpdateAdminPersona)
	admin.DELETE("/personas/:id", personas.DeleteAdminPersona)
	admin.POST("/personas/:id/avatar", func(c *gin.Context) { UploadPersonaAvatar(stores.Personas, c) })
	admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { DeletePersonaAvatar(stores.Personas, c) })
	admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs)   // v0.17.0
	admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs)   // v0.17.0
	admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
	admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0

	// Admin groups (v0.16.0)
	groupAdm := NewGroupHandler(stores)
	admin.GET("/groups", groupAdm.ListGroups)
	admin.POST("/groups", groupAdm.CreateGroup)
	admin.GET("/groups/:id", groupAdm.GetGroup)
	admin.PUT("/groups/:id", groupAdm.UpdateGroup)
	admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
	admin.GET("/groups/:id/members", groupAdm.ListMembers)
	admin.POST("/groups/:id/members", groupAdm.AddMember)
	admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)

	// Admin resource grants (v0.16.0)
	admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
	admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
	admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)

	// Admin permissions (v0.16.0)
	admin.GET("/permissions", groupAdm.ListPermissions)
	admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)

	// User groups (v0.16.0)
	protected.GET("/groups/mine", groupAdm.MyGroups)

	// Admin roles
	rolesH := NewRolesHandler(stores, roleResolver)
	admin.GET("/roles", rolesH.ListRoles)
	admin.GET("/roles/:role", rolesH.GetRole)
	admin.PUT("/roles/:role", rolesH.UpdateRole)

	// Admin usage + pricing
	usageH := NewUsageHandler(stores)
	admin.GET("/usage", usageH.AdminUsage)
	admin.GET("/usage/users/:id", usageH.AdminUserUsage)
	admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
	admin.GET("/pricing", usageH.ListPricing)
	admin.PUT("/pricing", usageH.UpsertPricing)
	admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)

	// Admin tasks
	taskAdm := NewTaskHandler(stores)
	admin.GET("/tasks", taskAdm.ListAll)
	admin.POST("/tasks/:id/run", taskAdm.RunNow)
	admin.POST("/tasks/:id/kill", taskAdm.KillRun)
	admin.DELETE("/tasks/:id", taskAdm.Delete)

	// Admin memory review
	adminMemH := NewMemoryHandler(stores)
	admin.GET("/memories/pending", adminMemH.ListPendingReview)
	admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)

	return &testHarness{router: r, t: t}
}

// makeToken generates a valid JWT for a test user.
func makeToken(userID, email, role string) string {
	claims := Claims{
		UserID: userID,
		Email:  email,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
			Issuer:    "chat-switchboard",
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	s, _ := token.SignedString([]byte(testJWTSecret))
	return s
}

// request makes an HTTP request and returns the response recorder.
func (h *testHarness) request(method, path, token string, body interface{}) *httptest.ResponseRecorder {
	h.t.Helper()
	var reader io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		reader = bytes.NewReader(b)
	}
	req := httptest.NewRequest(method, path, reader)
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.router.ServeHTTP(w, req)
	return w
}

// decode JSON response into target.
func decode(w *httptest.ResponseRecorder, target interface{}) error {
	return json.Unmarshal(w.Body.Bytes(), target)
}

// ── Seed Helpers ────────────────────────────

// registerUser registers a user via the API and returns the user_id + token.
func (h *testHarness) registerUser(username, email, password string) (userID, token string) {
	h.t.Helper()
	w := h.request("POST", "/api/v1/auth/register", "", map[string]string{
		"username": username, "email": email, "password": password,
	})
	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		h.t.Fatalf("register %s: want 200/201, got %d: %s", username, w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	token, _ = resp["access_token"].(string)
	if token == "" {
		h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s",
			username, w.Body.String())
	}

	// Extract user_id from profile
	w2 := h.request("GET", "/api/v1/profile", token, nil)
	var profile map[string]interface{}
	decode(w2, &profile)
	userID, _ = profile["id"].(string)
	return userID, token
}

// createAdminUser seeds an admin directly in DB, returns user_id + token.
func (h *testHarness) createAdminUser(username, email string) (userID, token string) {
	h.t.Helper()
	userID = database.SeedTestUser(h.t, username, email)
	// Make admin
	database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
	token = makeToken(userID, email, "admin")
	return
}

// ═══════════════════════════════════════════
//  TESTS
// ═══════════════════════════════════════════

// ── 1. Auth ─────────────────────────────────

func TestIntegration_Auth_Register(t *testing.T) {
	h := setupHarness(t)

	// Enable registration policy
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	w := h.request("POST", "/api/v1/auth/register", "", map[string]string{
		"username": "alice", "email": "alice@test.com", "password": "password123",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("register: want 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	decode(w, &resp)
	if resp["access_token"] == nil || resp["access_token"] == "" {
		t.Fatal("register should return access_token")
	}
}

func TestIntegration_Auth_Login(t *testing.T) {
	h := setupHarness(t)
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	h.registerUser("bob", "bob@test.com", "password123")

	w := h.request("POST", "/api/v1/auth/login", "", map[string]string{
		"login": "bob@test.com", "password": "password123",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("login: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	if resp["access_token"] == nil {
		t.Fatal("login should return access_token")
	}
}

func TestIntegration_Auth_ProfileRequiresToken(t *testing.T) {
	h := setupHarness(t)
	w := h.request("GET", "/api/v1/profile", "", nil)
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("profile without token: want 401, got %d", w.Code)
	}
}

// ── 2. Admin Users ──────────────────────────

func TestIntegration_AdminListUsers(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// page/per_page support
	w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin list users: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	users, ok := resp["data"].([]interface{})
	if !ok {
		t.Fatalf("response must have 'data' array, got %T", resp["data"])
	}
	if len(users) < 1 {
		t.Fatal("should have at least 1 user (admin)")
	}
	total, ok := resp["total"].(float64)
	if !ok || total < 1 {
		t.Fatalf("response must have 'total' >= 1, got %v", resp["total"])
	}
}

func TestIntegration_AdminListUsers_NonAdmin403(t *testing.T) {
	h := setupHarness(t)
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("user1", "user1@test.com", "password123")

	w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", userToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-admin list users: want 403, got %d", w.Code)
	}
}

// ── 3. Admin Settings / Policies ────────────

func TestIntegration_AdminPolicyRoundtrip(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	policies := []struct {
		key   string
		value string
	}{
		{"allow_registration", "false"},
		{"allow_user_byok", "true"},
		{"allow_user_personas", "true"},
		{"default_user_active", "true"},
	}

	for _, p := range policies {
		w := h.request("PUT", "/api/v1/admin/settings/"+p.key, adminToken,
			map[string]interface{}{"value": p.value})
		if w.Code != http.StatusOK {
			t.Fatalf("set policy %s: want 200, got %d: %s", p.key, w.Code, w.Body.String())
		}
	}

	// Verify via ListGlobalSettings
	w := h.request("GET", "/api/v1/admin/settings", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get settings: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	respPolicies, ok := resp["policies"].(map[string]interface{})
	if !ok {
		t.Fatalf("settings response must have 'policies' map, got %v", resp)
	}
	if respPolicies["allow_user_byok"] != "true" {
		t.Errorf("allow_user_byok: want 'true', got %v", respPolicies["allow_user_byok"])
	}
	if respPolicies["allow_user_personas"] != "true" {
		t.Errorf("allow_user_personas: want 'true', got %v", respPolicies["allow_user_personas"])
	}
}

func TestIntegration_PublicSettingsExposePolicies(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Set a policy
	h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
		map[string]interface{}{"value": "true"})

	// Public settings (no auth required)
	w := h.request("GET", "/api/v1/settings/public", "", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("public settings: want 200, got %d", w.Code)
	}
	var resp map[string]interface{}
	decode(w, &resp)
	policies, ok := resp["policies"].(map[string]interface{})
	if !ok {
		t.Fatalf("public settings must have 'policies', got %v", resp)
	}
	if policies["allow_user_byok"] != "true" {
		t.Errorf("public allow_user_byok: want 'true', got %v", policies["allow_user_byok"])
	}
	if policies["allow_user_personas"] == nil {
		t.Error("public settings should expose allow_user_personas")
	}
}

// ── 4. Provider Configs (Admin) ──────────────

func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "Test OpenAI", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test123",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	configID := created["id"].(string)

	// ── Verify API key is actually stored in DB ──
	var storedKey string
	err := database.TestDB.QueryRow(
		dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
	).Scan(&storedKey)
	if err != nil {
		t.Fatalf("query stored key: %v", err)
	}
	if storedKey != "sk-test123" {
		t.Fatalf("API key not stored: want 'sk-test123', got %q", storedKey)
	}

	// List
	w = h.request("GET", "/api/v1/admin/configs", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list configs: want 200, got %d", w.Code)
	}

	// ── Update API key via PUT ──
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken,
		map[string]interface{}{"api_key": "sk-updated456"})
	if w.Code != http.StatusOK {
		t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify the key was actually updated
	err = database.TestDB.QueryRow(
		dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
	).Scan(&storedKey)
	if err != nil {
		t.Fatalf("query updated key: %v", err)
	}
	if storedKey != "sk-updated456" {
		t.Fatalf("API key not updated: want 'sk-updated456', got %q", storedKey)
	}

	// Delete
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// TestIntegration_AdminProviderAPIKeyUsedByFetch verifies that the stored API
// key is actually passed to the provider when fetching models. This catches
// the json:"-" bug where CreateGlobalConfig silently dropped the API key.
func TestIntegration_AdminProviderAPIKeyUsedByFetch(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create provider with a deliberate bad key — we expect the fetch to
	// return an auth error FROM the provider, proving the key was sent.
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "KeyTest", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-badkey-for-test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	configID := cfg["id"].(string)

	// Verify key stored in DB
	var storedKey string
	database.TestDB.QueryRow(
		dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
	).Scan(&storedKey)
	if storedKey != "sk-badkey-for-test" {
		t.Fatalf("key not stored: want 'sk-badkey-for-test', got %q — json:\"-\" bug is back", storedKey)
	}

	// Fetch models — this will fail (bad key) but the error message should
	// contain an auth/HTTP error from OpenAI, NOT a DNS or empty-key error.
	// We're not testing OpenAI connectivity here, just that the key reaches
	// the provider layer.
	w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
		map[string]interface{}{"provider_config_id": configID})

	// Accept either 502 (single provider fetch fails) or 200 with errors array.
	// The key point: the handler TRIED to call the provider with our key.
	var fetchResp map[string]interface{}
	decode(w, &fetchResp)

	if w.Code == http.StatusOK {
		// Multi-provider path returns 200 with errors
		if errs, ok := fetchResp["errors"]; ok {
			errList := errs.([]interface{})
			if len(errList) > 0 {
				errMsg := errList[0].(string)
				t.Logf("  Provider returned error (expected): %s", errMsg)
			}
		}
	} else if w.Code == http.StatusBadGateway {
		// Single-provider path returns 502
		errMsg, _ := fetchResp["error"].(string)
		t.Logf("  Provider returned error (expected): %s", errMsg)
	} else {
		t.Fatalf("fetch models: unexpected status %d: %s", w.Code, w.Body.String())
	}
}

// ── 5. Model Visibility / Resolution ─────────

func TestIntegration_ModelVisibilityResolution(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
	_ = adminID

	// Create global provider config
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestProvider", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	configID := cfg["id"].(string)

	// Insert a model into catalog directly (simulating fetch)
	seedExec(t, `
		INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
		VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled')
	`, configID)

	// As admin, models/enabled should return empty (model disabled)
	w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var modelsResp map[string]interface{}
	decode(w, &modelsResp)
	modelsList := modelsResp["data"].([]interface{})
	if len(modelsList) != 0 {
		t.Errorf("disabled model should not appear, got %d models", len(modelsList))
	}

	// Enable the model
	var catalogID string
	database.TestDB.QueryRow(dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID).Scan(&catalogID)
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
		map[string]interface{}{"visibility": "enabled"})
	if w.Code != http.StatusOK {
		t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Now models/enabled should return 1 model
	w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
	}
	decode(w, &modelsResp)
	modelsList = modelsResp["data"].([]interface{})
	if len(modelsList) != 1 {
		t.Errorf("enabled model should appear, want 1 got %d", len(modelsList))
	}
}

// ── 6. Teams ─────────────────────────────────

func TestIntegration_TeamMemberManagement(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create regular user
	userID := database.SeedTestUser(t, "alice", "alice@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)

	// Create team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Engineering", "description": "Eng team",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// List users (verify response has 'users' field, not 'data')
	w = h.request("GET", "/api/v1/admin/users?page=1&per_page=200", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list users: want 200, got %d", w.Code)
	}
	var usersResp map[string]interface{}
	decode(w, &usersResp)
	if _, ok := usersResp["data"]; !ok {
		t.Fatal("admin/users response MUST have 'data' key")
	}
	users := usersResp["data"].([]interface{})
	if len(users) < 2 {
		t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users))
	}

	// Add member
	w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": userID, "role": "member"})
	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String())
	}

	// List members
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list members: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var membersResp map[string]interface{}
	decode(w, &membersResp)
	members := membersResp["data"].([]interface{})
	if len(members) != 1 {
		t.Fatalf("expected 1 member, got %d", len(members))
	}

	// Verify the user shows up in teams/mine
	userToken := makeToken(userID, "alice@test.com", "user")
	w = h.request("GET", "/api/v1/teams/mine", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("teams/mine: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── 7. Personas (Policy Gated) ───────────────

func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
	_ = adminID

	// Create a provider config (personas need a valid model reference)
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestProvider", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	var cfg map[string]interface{}
	decode(w, &cfg)
	configID := cfg["id"].(string)

	// Ensure allow_user_personas = false
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")

	// Try to create a persona as admin (role=admin but policy says no)
	w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
		"name": "My Persona", "base_model_id": "gpt-4o",
		"provider_config_id": configID, "system_prompt": "You are helpful",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Enable the policy
	h.request("PUT", "/api/v1/admin/settings/allow_user_personas", adminToken,
		map[string]interface{}{"value": "true"})

	// Now creation should succeed
	w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
		"name": "My Persona", "base_model_id": "gpt-4o",
		"provider_config_id": configID, "system_prompt": "You are helpful",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// List personas
	w = h.request("GET", "/api/v1/personas", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── 8. User Provider BYOK (Policy Gated) ────

func TestIntegration_UserBYOK_PolicyGated(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Ensure allow_user_byok = false
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	_, userToken := h.registerUser("bob", "bob@test.com", "password123")

	// Try to create a personal provider — should fail
	w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "MyKey", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-user123",
	})
	if w.Code == http.StatusCreated {
		t.Fatal("BYOK create should be blocked when allow_user_byok=false")
	}

	// Enable BYOK
	h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
		map[string]interface{}{"value": "true"})

	// Now should succeed
	w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "MyKey", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-user123",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("BYOK create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	cfgID := created["id"].(string)

	// List should only show personal configs, NOT global ones
	w = h.request("GET", "/api/v1/api-configs", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list configs: want 200, got %d", w.Code)
	}

	// Update
	w = h.request("PUT", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken,
		map[string]interface{}{"name": "MyKey-Updated"})
	if w.Code != http.StatusOK {
		t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Delete
	w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── 9. Notes CRUD ────────────────────────────

func TestIntegration_NotesCRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create
	w := h.request("POST", "/api/v1/notes", adminToken, map[string]interface{}{
		"title": "Test Note", "content": "Hello world", "folder": "general",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create note: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var note map[string]interface{}
	decode(w, &note)
	noteID := note["id"].(string)

	// Get
	w = h.request("GET", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get note: want 200, got %d", w.Code)
	}

	// Update
	w = h.request("PUT", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken,
		map[string]interface{}{"title": "Updated", "content": "Updated content"})
	if w.Code != http.StatusOK {
		t.Fatalf("update note: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List
	w = h.request("GET", "/api/v1/notes", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list notes: want 200, got %d", w.Code)
	}

	// Delete
	w = h.request("DELETE", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete note: want 200, got %d", w.Code)
	}
}

// ── 10. Cross-User Isolation ─────────────────

func TestIntegration_CrossUserIsolation(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	_, userAToken := h.registerUser("alice", "alice@test.com", "password123")
	_, userBToken := h.registerUser("charlie", "charlie@test.com", "password123")

	// Alice creates a provider
	w := h.request("POST", "/api/v1/api-configs", userAToken, map[string]interface{}{
		"name": "AliceKey", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-alice",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("alice create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var aliceCfg map[string]interface{}
	decode(w, &aliceCfg)
	aliceCfgID := aliceCfg["id"].(string)

	// Bob should NOT see Alice's provider
	w = h.request("GET", "/api/v1/api-configs", userBToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("bob list configs: want 200, got %d", w.Code)
	}

	// Bob should NOT be able to delete Alice's provider
	w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", aliceCfgID), userBToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("bob delete alice's config: want 403, got %d", w.Code)
	}

	// Admin should NOT see personal providers in admin list (they're in /admin/configs for global only)
	_ = adminToken
}

// ── 9. Admin Model Fetch → Enable → User Visibility ─────

func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
	_ = adminID

	// Create global provider config
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestProvider", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	configID := cfg["id"].(string)

	// Insert models directly (simulating successful provider fetch)
	for _, mid := range []string{"gpt-4o", "gpt-4o-mini", "o1-preview"} {
		seedExec(t, `
			INSERT INTO model_catalog (provider_config_id, model_id, display_name,
				capabilities, visibility)
			VALUES ($1, $2, $3, '{"streaming":true,"tool_calling":true}'::jsonb, 'disabled')
		`, configID, mid, mid)
	}

	// ── Admin list should show ALL models (including disabled) ──
	w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin list models: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var adminResp map[string]interface{}
	decode(w, &adminResp)
	adminModels := adminResp["data"].([]interface{})
	if len(adminModels) != 3 {
		t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels))
	}

	// Verify admin response is non-null array (not {"data": null})
	if adminResp["data"] == nil {
		t.Fatal("admin models must be [] not null — causes frontend fallback chain to break")
	}

	// ── User should see 0 models (all disabled) ──
	userID := database.SeedTestUser(t, "testuser", "user@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
	userToken := makeToken(userID, "user@test.com", "user")
	w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var userResp map[string]interface{}
	decode(w, &userResp)
	userModels := userResp["data"].([]interface{})
	if len(userModels) != 0 {
		t.Errorf("disabled models must not appear for user, got %d", len(userModels))
	}

	// Verify user response is non-null array
	if userResp["data"] == nil {
		t.Fatal("user models must be [] not null — causes '📋 Loaded 0 models' to crash")
	}

	// ── Admin enables one model ──
	var catalogID string
	database.TestDB.QueryRow(
		dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"),
		configID,
	).Scan(&catalogID)

	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
		map[string]interface{}{"visibility": "enabled"})
	if w.Code != http.StatusOK {
		t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── User should now see 1 model ──
	w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("user models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
	}
	decode(w, &userResp)
	userModels = userResp["data"].([]interface{})
	if len(userModels) != 1 {
		t.Fatalf("user should see 1 enabled model, got %d", len(userModels))
	}

	// ── Validate response shape matches frontend contract ──
	m := userModels[0].(map[string]interface{})

	// Backend MUST send provider_config_id (Go struct canonical)
	if m["provider_config_id"] == nil || m["provider_config_id"] == "" {
		t.Error("MISSING: provider_config_id — Go struct canonical field")
	}

	// Backend MUST send config_id alias (frontend reads this)
	if m["config_id"] == nil || m["config_id"] == "" {
		t.Error("MISSING: config_id — frontend alias, composite IDs will break")
	}

	// Alias must match canonical
	if m["config_id"] != m["provider_config_id"] {
		t.Errorf("config_id (%v) must equal provider_config_id (%v)",
			m["config_id"], m["provider_config_id"])
	}

	// model_id required for composite ID construction
	if m["model_id"] == nil || m["model_id"] == "" {
		t.Error("MISSING: model_id — required for composite ID")
	}

	// source required for persona detection
	if m["source"] == nil || m["source"] == "" {
		t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona")
	}

	// provider_name required for display
	if m["provider_name"] == nil || m["provider_name"] == "" {
		t.Error("MISSING: provider_name — frontend model selector display")
	}

	// capabilities must be object not null
	if m["capabilities"] == nil {
		t.Error("capabilities must not be null")
	}
}


// ═══════════════════════════════════════════════════
// USER JOURNEY TESTS — API calls only
// ═══════════════════════════════════════════════════
// These tests exercise the ACTUAL user experience.
// No insertModel(). No insertProvider(). Only API calls.
//
// The ONLY raw SQL allowed is labeled [SIMULATED FETCH]
// to represent what an external provider API would return,
// since integration tests can't hit real OpenAI/Venice APIs.
// ═══════════════════════════════════════════════════

// ── Test helpers ──

// getModels calls GET /models/enabled and returns the model list.
func (h *testHarness) getModels(token string) []interface{} {
	h.t.Helper()
	w := h.request("GET", "/api/v1/models/enabled", token, nil)
	if w.Code != http.StatusOK {
		h.t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	if resp["data"] == nil {
		h.t.Fatal("models response must never be null")
	}
	return resp["data"].([]interface{})
}

func getModelIDs(models []interface{}) []string {
	ids := make([]string, 0, len(models))
	for _, raw := range models {
		m := raw.(map[string]interface{})
		if mid, ok := m["model_id"].(string); ok {
			ids = append(ids, mid)
		}
	}
	return ids
}

func hasModel(models []interface{}, modelID string) bool {
	for _, raw := range models {
		m := raw.(map[string]interface{})
		if m["model_id"] == modelID {
			return true
		}
	}
	return false
}

func hasModelWithScope(models []interface{}, modelID, scope string) bool {
	for _, raw := range models {
		m := raw.(map[string]interface{})
		if m["model_id"] == modelID && m["scope"] == scope {
			return true
		}
	}
	return false
}

// simulateFetch inserts models into model_catalog as if a provider API returned them.
// This is the ONLY raw SQL in journey tests — clearly labeled because integration
// tests cannot hit real external APIs (OpenAI, Venice, etc).
func simulateFetch(t *testing.T, providerConfigID string, models []string, visibility string) {
	t.Helper()
	for _, modelID := range models {
		seedExec(t, `
			INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
			VALUES ($1, $2, $3, $4)
		`, providerConfigID, modelID, modelID, visibility)
	}
}

// ── Journey 1: Admin creates provider → user sees models ──

func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Regular user — no special role, no team
	userID := database.SeedTestUser(t, "alice", "alice@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
	userToken := makeToken(userID, "alice@test.com", "user")

	// Step 1: Admin creates provider via API
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestOpenAI", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("admin create config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	configID := cfg["id"].(string)

	// Step 2: [SIMULATED FETCH] — represents POST /admin/models/fetch hitting OpenAI API
	// In production: admin clicks "Fetch Models" → backend calls OpenAI → inserts into catalog
	// In test: we insert directly because we can't call a real API
	simulateFetch(t, configID, []string{"gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"}, "disabled")

	// Step 3: User should see 0 models (all disabled)
	userModels := h.getModels(userToken)
	if len(userModels) != 0 {
		t.Fatalf("before admin enables: user should see 0 models, got %d", len(userModels))
	}

	// Step 4: Admin enables one model via API
	var catalogID string
	database.TestDB.QueryRow(
		dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID,
	).Scan(&catalogID)

	w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
		map[string]interface{}{"visibility": "enabled"})
	if w.Code != http.StatusOK {
		t.Fatalf("admin enable model: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Step 5: User should now see exactly 1 model
	userModels = h.getModels(userToken)
	if len(userModels) != 1 {
		t.Fatalf("after admin enables gpt-4o: user should see 1 model, got %d: %v",
			len(userModels), getModelIDs(userModels))
	}

	// Step 6: Verify the model has all required frontend fields
	m := userModels[0].(map[string]interface{})
	if m["model_id"] != "gpt-4o" {
		t.Errorf("expected model_id=gpt-4o, got %v", m["model_id"])
	}
	if m["config_id"] == nil || m["config_id"] == "" {
		t.Error("MISSING config_id — frontend needs this for composite model ID")
	}
	if m["provider_name"] == nil || m["provider_name"] == "" {
		t.Error("MISSING provider_name — frontend model selector display")
	}
	if m["scope"] != "global" {
		t.Errorf("expected scope=global, got %v", m["scope"])
	}
}

// ── Journey 2: User creates BYOK provider → auto-fetch triggers ──
//
// After the fix in apiconfigs.go, CreateConfig now auto-fetches models
// from the provider API and auto-enables them. In integration tests,
// the real API isn't reachable so we get a warning — but the provider
// is still created (201). The Live Venice test validates the real flow.

func TestUserJourney_BYOK_AutoFetchTriggered(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Enable BYOK policy
	h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
		map[string]interface{}{"value": "true"})

	// Regular user
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("bob", "bob@test.com", "password123")

	// Step 1: User creates BYOK provider via API
	w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "My OpenAI Key", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-fake-key",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// Step 2: Response should include the provider ID and a fetch result.
	// In integration tests, the real API isn't reachable, so we expect:
	//   - id: present (provider was created)
	//   - warning: present (fetch failed — no real API in test)
	//   - models_fetched: 0 (or absent)
	var created map[string]interface{}
	decode(w, &created)
	cfgID := created["id"].(string)
	if cfgID == "" {
		t.Fatal("provider creation should return an id")
	}

	// Warning is expected in integration tests (can't reach real OpenAI API)
	if created["warning"] != nil {
		t.Logf("expected warning in test env: %v", created["warning"])
	}

	// Step 3: models/enabled → 0 personal models (fetch failed, catalog empty)
	// This is CORRECT behavior in test env. Live Venice test validates real flow.
	userModels := h.getModels(userToken)
	personalCount := 0
	for _, raw := range userModels {
		m := raw.(map[string]interface{})
		if m["scope"] == "personal" {
			personalCount++
		}
	}
	t.Logf("personal models after auto-fetch (test env, no real API): %d", personalCount)
}

// ── Journey 3: BYOK models exist in catalog but NULL scan kills them ──
//
// Even if we manually populate the catalog for a BYOK provider
// (simulating what auto-fetch WOULD do), the provider scan fails
// because model_default is NULL and scanProviders uses bare string.
//
// This test FAILS before the provider.go fix, PASSES after.

func TestUserJourney_BYOK_NullScanKillsModels(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Enable BYOK
	h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
		map[string]interface{}{"value": "true"})

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("alice", "alice@test.com", "password123")

	// User creates BYOK provider via API
	// Use unreachable endpoint so auto-fetch fails — simulateFetch controls the catalog.
	w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "My Venice", "provider": "venice",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-alice-key",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	cfgID := created["id"].(string)

	// [SIMULATED FETCH] — what auto-fetch SHOULD do after provider creation
	// Inserts models with visibility='enabled' (BYOK models should be auto-enabled)
	simulateFetch(t, cfgID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled")

	// User calls models/enabled — should see their 2 personal models
	userModels := h.getModels(userToken)
	personalModels := 0
	for _, raw := range userModels {
		m := raw.(map[string]interface{})
		if m["scope"] == "personal" {
			personalModels++
		}
	}

	// Before provider.go NULL scan fix: personalModels = 0 (scan crashes, models silently lost)
	// After fix: personalModels = 2
	if personalModels != 2 {
		t.Fatalf("user should see 2 personal BYOK models, got %d\n"+
			"  If 0: provider.go scanProviders crashes on NULL model_default\n"+
			"  Check: warn: failed to load personal providers: sql: Scan error",
			personalModels)
	}

	// Verify the models have correct scope and fields
	if !hasModelWithScope(userModels, "llama-3.3-70b", "personal") {
		t.Error("llama-3.3-70b should appear with scope=personal")
	}
	if !hasModelWithScope(userModels, "deepseek-r1", "personal") {
		t.Error("deepseek-r1 should appear with scope=personal")
	}
}

// ── Journey 4: Team provider → member sees, non-member doesn't ──

func TestUserJourney_TeamProvider_MemberVsNonMember(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create team members
	aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
	aliceToken := makeToken(aliceID, "alice@test.com", "user")

	bobID := database.SeedTestUser(t, "bob", "bob@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
	bobToken := makeToken(bobID, "bob@test.com", "user")

	// Step 1: Admin creates team via API
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Engineering", "description": "Eng team",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team: %d: %s", w.Code, w.Body.String())
	}
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Step 2: Admin adds alice as team admin, bob is NOT added
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": aliceID, "role": "admin"})

	// Step 3: Team admin (alice) creates team provider via self-service API
	w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), aliceToken,
		map[string]interface{}{
			"name": "Team Venice", "provider": "venice",
			"endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team-key",
		})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team provider: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var prov map[string]interface{}
	decode(w, &prov)
	provID := prov["id"].(string)

	// Step 4: [SIMULATED FETCH] — what fetching from Venice API would return
	simulateFetch(t, provID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled")

	// Step 5: Also add a global model so we can verify additive behavior
	gw := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "GlobalOpenAI", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
	})
	var gcfg map[string]interface{}
	decode(gw, &gcfg)
	globalCfgID := gcfg["id"].(string)
	simulateFetch(t, globalCfgID, []string{"gpt-4o"}, "enabled")

	// Step 6: Alice (team member) sees global + team models
	aliceModels := h.getModels(aliceToken)
	if !hasModel(aliceModels, "gpt-4o") {
		t.Error("alice should see global model gpt-4o")
	}
	if !hasModel(aliceModels, "llama-3.3-70b") {
		t.Errorf("alice (team member) should see team model llama-3.3-70b, got: %v", getModelIDs(aliceModels))
	}

	// Step 7: Bob (NOT in team) sees ONLY global models
	bobModels := h.getModels(bobToken)
	if !hasModel(bobModels, "gpt-4o") {
		t.Error("bob should see global model gpt-4o")
	}
	if hasModel(bobModels, "llama-3.3-70b") {
		t.Error("bob (non-member) should NOT see team model llama-3.3-70b")
	}
	if hasModel(bobModels, "deepseek-r1") {
		t.Error("bob (non-member) should NOT see team model deepseek-r1")
	}
}

// ── Journey 5: Full 4-actor matrix ──
//
// All providers created via API. All assertions via API.
// Only raw SQL is [SIMULATED FETCH].
//
// Actors:
//   platformAdmin  — system admin, NOT in any team
//   teamAdmin      — team_members.role='admin' in Engineering
//   teamMember     — team_members.role='member' in Engineering
//   outsider       — regular user, no team
//
// Expected:
//   | Actor        | Global(en) | Team(en) | BYOK(own) | Total |
//   |--------------|------------|----------|-----------|-------|
//   | platformAdmin| 2          | 0        | 0         | 2     |
//   | teamAdmin    | 2          | 1        | 0         | 3     |
//   | teamMember   | 2          | 1        | 1 *       | 4     |
//   | outsider     | 2          | 0        | 1 *       | 3     |
//
//   * BYOK models require provider.go NULL scan fix

func TestUserJourney_FullMatrix(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("platformadmin", "platformadmin@test.com")
	_ = adminID

	// Create all actors
	teamAdminID := database.SeedTestUser(t, "teamadmin", "teamadmin@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamadmin@test.com", "user")

	teamMemberID := database.SeedTestUser(t, "teammember", "teammember@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamMemberID)
	teamMemberToken := makeToken(teamMemberID, "teammember@test.com", "user")

	outsiderID := database.SeedTestUser(t, "outsider", "outsider@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), outsiderID)
	outsiderToken := makeToken(outsiderID, "outsider@test.com", "user")

	// ── Setup: Enable BYOK policy ──
	h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
		map[string]interface{}{"value": "true"})

	// ── Setup: Create team via admin API ──
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Engineering", "description": "Eng team",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team: %d", w.Code)
	}
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Add teamAdmin and teamMember to team (platformAdmin and outsider are NOT added)
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamMemberID, "role": "member"})

	// ── Setup: Global provider via admin API ──
	w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "GlobalOpenAI", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create global config: %d", w.Code)
	}
	var gcfg map[string]interface{}
	decode(w, &gcfg)
	globalCfgID := gcfg["id"].(string)

	// [SIMULATED FETCH] for global provider: 2 enabled + 1 disabled
	simulateFetch(t, globalCfgID, []string{"gpt-4o", "gpt-4o-mini"}, "enabled")
	simulateFetch(t, globalCfgID, []string{"gpt-3.5-turbo"}, "disabled")

	// ── Setup: Team provider via team admin self-service API ──
	w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
		map[string]interface{}{
			"name": "TeamVenice", "provider": "venice",
			"endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team",
		})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
	}
	var tprov map[string]interface{}
	decode(w, &tprov)
	teamProvID := tprov["id"].(string)

	// [SIMULATED FETCH] for team provider: 1 enabled + 1 disabled
	simulateFetch(t, teamProvID, []string{"llama-3.3-70b"}, "enabled")
	simulateFetch(t, teamProvID, []string{"deepseek-r1"}, "disabled")

	// ── Setup: BYOK providers via user API ──
	// Use unreachable endpoints so auto-fetch fails — simulateFetch controls catalog.
	w = h.request("POST", "/api/v1/api-configs", teamMemberToken, map[string]interface{}{
		"name": "MemberKey", "provider": "openai",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-member",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create member BYOK: %d: %s", w.Code, w.Body.String())
	}
	var memberCfg map[string]interface{}
	decode(w, &memberCfg)
	memberBYOKID := memberCfg["id"].(string)

	// [SIMULATED FETCH] for member BYOK
	simulateFetch(t, memberBYOKID, []string{"gpt-4o-member-byok"}, "enabled")

	w = h.request("POST", "/api/v1/api-configs", outsiderToken, map[string]interface{}{
		"name": "OutsiderKey", "provider": "venice",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-outsider",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create outsider BYOK: %d: %s", w.Code, w.Body.String())
	}
	var outsiderCfg map[string]interface{}
	decode(w, &outsiderCfg)
	outsiderBYOKID := outsiderCfg["id"].(string)

	// [SIMULATED FETCH] for outsider BYOK
	simulateFetch(t, outsiderBYOKID, []string{"llama-outsider-byok"}, "enabled")

	// ════════════════════════════════════════════
	// ASSERTIONS — what each actor actually sees
	// ════════════════════════════════════════════

	t.Run("platformAdmin_sees_global_only", func(t *testing.T) {
		models := h.getModels(adminToken)
		ids := getModelIDs(models)
		if len(models) != 2 {
			t.Fatalf("platformAdmin: want 2 (global enabled), got %d: %v", len(models), ids)
		}
		if !hasModel(models, "gpt-4o") || !hasModel(models, "gpt-4o-mini") {
			t.Errorf("platformAdmin should see gpt-4o and gpt-4o-mini, got: %v", ids)
		}
		if hasModel(models, "gpt-3.5-turbo") {
			t.Error("platformAdmin should NOT see disabled gpt-3.5-turbo")
		}
		if hasModel(models, "llama-3.3-70b") {
			t.Error("platformAdmin should NOT see team model (not in team)")
		}
	})

	t.Run("teamAdmin_sees_global_plus_team", func(t *testing.T) {
		models := h.getModels(teamAdminToken)
		ids := getModelIDs(models)
		if len(models) != 3 {
			t.Fatalf("teamAdmin: want 3 (2 global + 1 team), got %d: %v", len(models), ids)
		}
		if !hasModel(models, "llama-3.3-70b") {
			t.Errorf("teamAdmin should see team model llama-3.3-70b, got: %v", ids)
		}
		if hasModel(models, "deepseek-r1") {
			t.Error("teamAdmin should NOT see disabled team model deepseek-r1")
		}
	})

	t.Run("teamMember_sees_global_plus_team_plus_byok", func(t *testing.T) {
		models := h.getModels(teamMemberToken)
		ids := getModelIDs(models)
		if len(models) != 4 {
			t.Fatalf("teamMember: want 4 (2 global + 1 team + 1 BYOK), got %d: %v\n"+
				"  If 3: provider.go NULL scan bug is hiding BYOK models\n"+
				"  If 2: team provider scan also failing",
				len(models), ids)
		}
		if !hasModelWithScope(models, "gpt-4o-member-byok", "personal") {
			t.Error("teamMember should see own BYOK model with scope=personal")
		}
		if hasModel(models, "llama-outsider-byok") {
			t.Error("teamMember should NOT see outsider's BYOK model")
		}
	})

	t.Run("outsider_sees_global_plus_own_byok", func(t *testing.T) {
		models := h.getModels(outsiderToken)
		ids := getModelIDs(models)
		if len(models) != 3 {
			t.Fatalf("outsider: want 3 (2 global + 1 BYOK), got %d: %v\n"+
				"  If 2: provider.go NULL scan bug is hiding BYOK models",
				len(models), ids)
		}
		if !hasModelWithScope(models, "llama-outsider-byok", "personal") {
			t.Error("outsider should see own BYOK model with scope=personal")
		}
		if hasModel(models, "llama-3.3-70b") {
			t.Error("outsider should NOT see team model (not in team)")
		}
		if hasModel(models, "gpt-4o-member-byok") {
			t.Error("outsider should NOT see teamMember's BYOK model")
		}
	})

	// ── Dynamic state changes ──

	t.Run("byok_policy_off_hides_personal_models", func(t *testing.T) {
		h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
			map[string]interface{}{"value": "false"})
		defer h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
			map[string]interface{}{"value": "true"})

		models := h.getModels(teamMemberToken)
		if hasModelWithScope(models, "gpt-4o-member-byok", "personal") {
			t.Error("BYOK off: teamMember should NOT see personal models")
		}
		if len(models) != 3 {
			t.Fatalf("BYOK off: teamMember want 3 (2 global + 1 team), got %d: %v",
				len(models), getModelIDs(models))
		}

		models = h.getModels(outsiderToken)
		if len(models) != 2 {
			t.Fatalf("BYOK off: outsider want 2 (global only), got %d: %v",
				len(models), getModelIDs(models))
		}
	})

	t.Run("admin_disables_global_model_users_lose_it", func(t *testing.T) {
		var catalogID string
		database.TestDB.QueryRow(
			dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), globalCfgID,
		).Scan(&catalogID)

		// Admin disables gpt-4o
		w := h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
			map[string]interface{}{"visibility": "disabled"})
		if w.Code != http.StatusOK {
			t.Fatalf("disable model: %d", w.Code)
		}
		defer func() {
			h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
				map[string]interface{}{"visibility": "enabled"})
		}()

		// Everyone loses gpt-4o
		for _, tc := range []struct {
			name  string
			token string
		}{
			{"platformAdmin", adminToken},
			{"teamAdmin", teamAdminToken},
			{"teamMember", teamMemberToken},
			{"outsider", outsiderToken},
		} {
			models := h.getModels(tc.token)
			if hasModel(models, "gpt-4o") {
				t.Errorf("%s should NOT see disabled gpt-4o", tc.name)
			}
		}
	})

	t.Run("admin_models_shows_global_only", func(t *testing.T) {
		w := h.request("GET", "/api/v1/admin/models", adminToken, nil)
		if w.Code != http.StatusOK {
			t.Fatalf("admin/models: %d", w.Code)
		}
		var resp map[string]interface{}
		decode(w, &resp)
		allModels := resp["data"].([]interface{})
		// Admin should see only global-scope models (3), not team(2) or BYOK(2)
		if len(allModels) != 3 {
			t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels))
		}
	})
}

// ═══════════════════════════════════════════
//  ROLES TESTS
// ═══════════════════════════════════════════

func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	w := h.request("GET", "/api/v1/admin/roles", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list roles: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)

	// Migration 004 seeds utility, embedding, generation
	// (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data)
	for _, role := range []string{"utility", "embedding", "generation"} {
		if _, ok := resp[role]; !ok {
			t.Errorf("expected role %q in response, got: %v", role, resp)
		}
	}
}

func TestIntegration_Roles_UpdateAndGet(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create a global provider to reference
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestProvider", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	cfgID := cfg["id"].(string)

	// Update utility role
	w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
		"primary": map[string]string{
			"provider_config_id": cfgID,
			"model_id":          "gpt-4o-mini",
		},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update role: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify via list
	w = h.request("GET", "/api/v1/admin/roles", adminToken, nil)
	var allRoles map[string]interface{}
	decode(w, &allRoles)
	utilRaw, ok := allRoles["utility"]
	if !ok {
		t.Fatal("utility role missing after update")
	}
	utilMap, ok := utilRaw.(map[string]interface{})
	if !ok {
		t.Fatalf("utility role unexpected type: %T", utilRaw)
	}
	primary, _ := utilMap["primary"].(map[string]interface{})
	if primary == nil {
		t.Fatal("utility primary should be set")
	}
	if primary["model_id"] != "gpt-4o-mini" {
		t.Errorf("utility primary model: want gpt-4o-mini, got %v", primary["model_id"])
	}
}

func TestIntegration_Roles_InvalidRole400(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	w := h.request("GET", "/api/v1/admin/roles/nonexistent", adminToken, nil)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("invalid role: want 400, got %d", w.Code)
	}
}

func TestIntegration_Roles_NonAdmin403(t *testing.T) {
	h := setupHarness(t)
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("user1", "user1@test.com", "password123")

	w := h.request("GET", "/api/v1/admin/roles", userToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-admin roles: want 403, got %d", w.Code)
	}
}

// ── Team Roles ────────────────────────────

func TestIntegration_TeamRoles_CRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create team admin user
	teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")

	// Create team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Eng", "description": "Engineering",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Add team admin
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})

	// Create a provider to reference
	w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "Provider1", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	var pcfg map[string]interface{}
	decode(w, &pcfg)
	cfgID := pcfg["id"].(string)

	// List team roles — initially empty
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
	}
	var roleResp map[string]interface{}
	decode(w, &roleResp)
	if len(roleResp) != 0 {
		t.Fatalf("team roles should be empty initially, got %d", len(roleResp))
	}

	// Set team role override
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken,
		map[string]interface{}{
			"primary": map[string]string{
				"provider_config_id": cfgID,
				"model_id":          "gpt-4o",
			},
		})
	if w.Code != http.StatusOK {
		t.Fatalf("set team role: %d: %s", w.Code, w.Body.String())
	}

	// List should now have override
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
	roleResp = map[string]interface{}{}
	decode(w, &roleResp)
	if len(roleResp) == 0 {
		t.Fatal("team roles should have utility after update")
	}
	if _, ok := roleResp["utility"]; !ok {
		t.Fatal("team roles should have utility after update")
	}

	// Delete override
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete team role: %d: %s", w.Code, w.Body.String())
	}

	// List should be empty again
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
	roleResp = map[string]interface{}{}
	decode(w, &roleResp)
	if _, ok := roleResp["utility"]; ok {
		t.Fatal("team roles should not have utility after delete")
	}
}

// ═══════════════════════════════════════════
//  USAGE TESTS
// ═══════════════════════════════════════════

// seedUsage inserts a usage_log row directly for testing.
func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
	t.Helper()
	seedExec(t, `
		INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
		                       model_id, prompt_tokens, completion_tokens,
		                       cost_input, cost_output)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
	`, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
}

func TestIntegration_Usage_AdminView(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	userID := database.SeedTestUser(t, "alice", "alice@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)

	// Create global provider
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "TestOAI", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
	})
	var cfg map[string]interface{}
	decode(w, &cfg)
	cfgID := cfg["id"].(string)

	// Seed usage data
	seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.005, 0.006)
	seedUsage(t, userID, cfgID, "gpt-4o", "global", 500, 100, 0.0025, 0.003)
	seedUsage(t, userID, cfgID, "gpt-4o-mini", "global", 2000, 400, 0.001, 0.002)

	// Admin usage — grouped by model (default)
	w = h.request("GET", "/api/v1/admin/usage?group_by=model", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)

	totals := resp["totals"].(map[string]interface{})
	if int(totals["requests"].(float64)) != 3 {
		t.Errorf("totals.requests: want 3, got %v", totals["requests"])
	}
	if int(totals["input_tokens"].(float64)) != 3500 {
		t.Errorf("totals.input_tokens: want 3500, got %v", totals["input_tokens"])
	}

	results := resp["results"].([]interface{})
	if len(results) != 2 {
		t.Fatalf("want 2 model groups, got %d", len(results))
	}
}

func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	userID := database.SeedTestUser(t, "bob", "bob@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)

	// Create global provider
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "Global1", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-g",
	})
	var cfg map[string]interface{}
	decode(w, &cfg)
	globalCfgID := cfg["id"].(string)

	// Seed: 1 global usage + 1 personal BYOK usage
	seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
	seedUsage(t, userID, globalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)

	// Admin view should exclude personal
	w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
	var resp map[string]interface{}
	decode(w, &resp)
	totals := resp["totals"].(map[string]interface{})

	if int(totals["requests"].(float64)) != 1 {
		t.Errorf("admin should see 1 request (excludes BYOK), got %v", totals["requests"])
	}
}

func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	userID, userToken := h.registerUser("carol", "carol@test.com", "password123")

	// Create global provider (admin-managed)
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "Global2", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
	})
	var globalCfg map[string]interface{}
	decode(w, &globalCfg)
	globalCfgID := globalCfg["id"].(string)

	// Create personal BYOK provider (user-managed)
	w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "MyKey", "provider": "openai",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var personalCfg map[string]interface{}
	decode(w, &personalCfg)
	personalCfgID := personalCfg["id"].(string)

	// Seed usage against both providers
	seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
	seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)

	// Personal view should only show BYOK usage (not global)
	w = h.request("GET", "/api/v1/usage", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	totals := resp["totals"].(map[string]interface{})

	if int(totals["requests"].(float64)) != 1 {
		t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"])
	}
	if int(totals["input_tokens"].(float64)) != 500 {
		t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"])
	}

	// Admin view should show global usage but exclude BYOK
	w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
	}
	decode(w, &resp)
	totals = resp["totals"].(map[string]interface{})

	if int(totals["requests"].(float64)) != 1 {
		t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"])
	}
	if int(totals["input_tokens"].(float64)) != 1000 {
		t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"])
	}
}

func TestIntegration_Usage_NonAdmin403(t *testing.T) {
	h := setupHarness(t)
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("dave", "dave@test.com", "password123")

	w := h.request("GET", "/api/v1/admin/usage", userToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-admin usage: want 403, got %d", w.Code)
	}
}

// ── Team Usage ────────────────────────────

func TestIntegration_Usage_TeamAdmin(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create team admin + member
	teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")

	memberID := database.SeedTestUser(t, "member2", "member2@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)

	// Create team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "EngTeam", "description": "Test",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": memberID, "role": "member"})

	// Create team provider via team admin self-service
	w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
		map[string]interface{}{
			"name": "TeamOpenAI", "provider": "openai",
			"endpoint": "https://api.openai.com/v1", "api_key": "sk-team",
		})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
	}
	var tprov map[string]interface{}
	decode(w, &tprov)
	teamProvID := tprov["id"].(string)

	// Also create a global provider (usage against it should NOT appear in team view)
	w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "GlobalProv", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
	})
	var gcfg map[string]interface{}
	decode(w, &gcfg)
	globalProvID := gcfg["id"].(string)

	// Seed: 2 rows against team provider, 1 against global
	seedUsage(t, memberID, teamProvID, "gpt-4o", "team", 1000, 200, 0.01, 0.01)
	seedUsage(t, teamAdminID, teamProvID, "gpt-4o", "team", 500, 100, 0.005, 0.005)
	seedUsage(t, memberID, globalProvID, "gpt-4o", "global", 2000, 400, 0.02, 0.02)

	// Team usage should show only team provider usage (2 rows)
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("team usage: %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	totals := resp["totals"].(map[string]interface{})

	if int(totals["requests"].(float64)) != 2 {
		t.Errorf("team usage should show 2 requests (team provider only), got %v", totals["requests"])
	}
	if int(totals["input_tokens"].(float64)) != 1500 {
		t.Errorf("team usage input_tokens: want 1500, got %v", totals["input_tokens"])
	}
}

func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	memberID := database.SeedTestUser(t, "member3", "member3@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
	memberToken := makeToken(memberID, "member3@test.com", "user")

	// Create team, add member (NOT admin)
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "EngTeam2", "description": "Test2",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": memberID, "role": "member"})

	// Non-admin member should be forbidden
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), memberToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("team usage non-admin: want 403, got %d", w.Code)
	}
}

// ═══════════════════════════════════════════
//  PRICING TESTS
// ═══════════════════════════════════════════

func TestIntegration_Pricing_CRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create provider to reference
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "PricingProv", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-p",
	})
	var cfg map[string]interface{}
	decode(w, &cfg)
	cfgID := cfg["id"].(string)

	// Upsert pricing
	w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
		"provider_config_id": cfgID,
		"model_id":           "gpt-4o",
		"input_per_m":        2.5,
		"output_per_m":       10.0,
	})
	if w.Code != http.StatusOK {
		t.Fatalf("upsert pricing: %d: %s", w.Code, w.Body.String())
	}

	// List pricing
	w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list pricing: %d: %s", w.Code, w.Body.String())
	}
	var entries []interface{}
	decode(w, &entries)
	if len(entries) != 1 {
		t.Fatalf("want 1 pricing entry, got %d", len(entries))
	}
	entry := entries[0].(map[string]interface{})
	if entry["model_id"] != "gpt-4o" {
		t.Errorf("pricing model: want gpt-4o, got %v", entry["model_id"])
	}
	if entry["source"] != "manual" {
		t.Errorf("pricing source: want manual, got %v", entry["source"])
	}

	// Delete pricing
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/pricing/%s/gpt-4o", cfgID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete pricing: %d: %s", w.Code, w.Body.String())
	}

	// List should be empty
	w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
	decode(w, &entries)
	if len(entries) != 0 {
		t.Fatalf("want 0 pricing entries after delete, got %d", len(entries))
	}
}

func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("byokuser", "byokuser@test.com", "password123")

	// Global provider with pricing
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "GlobalP", "provider": "openai",
		"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
	})
	var gcfg map[string]interface{}
	decode(w, &gcfg)
	globalID := gcfg["id"].(string)

	// Set global pricing
	h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
		"provider_config_id": globalID,
		"model_id":           "gpt-4o",
		"input_per_m":        2.5,
		"output_per_m":       10.0,
	})

	// BYOK provider (personal scope)
	w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "MyKey", "provider": "openai",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-byok",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create BYOK: %d: %s", w.Code, w.Body.String())
	}
	var bcfg map[string]interface{}
	decode(w, &bcfg)
	byokID := bcfg["id"].(string)

	// Simulate catalog pricing for BYOK provider (as model sync would)
	seedExec(t, `
		INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
		VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
	`, byokID)

	// Admin list should only show the 1 global entry, not the BYOK one
	w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
	var entries []interface{}
	decode(w, &entries)
	if len(entries) != 1 {
		t.Fatalf("admin pricing should show 1 (global only), got %d", len(entries))
	}
	entry := entries[0].(map[string]interface{})
	if entry["provider_config_id"] != globalID {
		t.Errorf("pricing entry should be global provider, got %v", entry["provider_config_id"])
	}
}

func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("byokuser2", "byokuser2@test.com", "password123")

	// Create BYOK provider
	w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
		"name": "MyKey2", "provider": "openai",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-byok2",
	})
	var bcfg map[string]interface{}
	decode(w, &bcfg)
	byokID := bcfg["id"].(string)

	// Admin tries to set pricing on personal provider — should be rejected
	w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
		"provider_config_id": byokID,
		"model_id":           "gpt-4o",
		"input_per_m":        2.5,
		"output_per_m":       10.0,
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
	}
}

// ── GetRole Endpoint (exercises GetConfig signature) ──
// The existing Roles_UpdateAndGet test validates via ListRoles.
// This test hits GET /admin/roles/:role which routes through
// resolver.GetConfig — the exact call site that broke in v0.10.2
// when the signature changed from 3-arg to 4-arg.

func TestIntegration_Roles_GetSingleRole(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com")

	// Create provider + configure utility role
	w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
		"name": "getrole-provider", "provider": "openai",
		"endpoint": "http://localhost:1/v1", "api_key": "sk-getrole",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
	}
	var cfg map[string]interface{}
	decode(w, &cfg)
	cfgID := cfg["id"].(string)

	w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
		"primary": map[string]string{
			"provider_config_id": cfgID,
			"model_id":          "gpt-4o-mini",
		},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update role: %d: %s", w.Code, w.Body.String())
	}

	// GET /admin/roles/utility — the endpoint that broke
	w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Seeded role with null config via GetRole → 200 (migration seeds all roles)
	w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── KB CRUD ─────────────────────────────────────

func TestIntegration_KnowledgeBaseCRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create personal KB
	w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Test KB", "description": "A test knowledge base",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var kb map[string]interface{}
	decode(w, &kb)
	kbID := kb["id"].(string)

	if kb["name"] != "Test KB" {
		t.Fatalf("create KB: name mismatch: got %s", kb["name"])
	}
	if kb["scope"] != "personal" {
		t.Fatalf("create KB: scope should default to personal: got %s", kb["scope"])
	}

	// List KBs
	w = h.request("GET", "/api/v1/knowledge-bases", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list KBs: want 200, got %d", w.Code)
	}
	var kbList map[string]interface{}
	decode(w, &kbList)
	items := kbList["data"].([]interface{})
	if len(items) != 1 {
		t.Fatalf("list KBs: expected 1, got %d", len(items))
	}

	// Get single KB
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get KB: want 200, got %d", w.Code)
	}

	// Update KB
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken,
		map[string]interface{}{"name": "Updated KB"})
	if w.Code != http.StatusOK {
		t.Fatalf("update KB: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify update
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
	var updated map[string]interface{}
	decode(w, &updated)
	if updated["name"] != "Updated KB" {
		t.Fatalf("update KB: name not updated, got %s", updated["name"])
	}

	// Upload document — should 503 (no object store in test harness)
	w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
	if w.Code != http.StatusServiceUnavailable {
		t.Fatalf("upload doc without objstore: want 503, got %d", w.Code)
	}

	// List documents (empty)
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list docs: want 200, got %d", w.Code)
	}

	// Rebuild — should 503 (no ingester in test harness)
	w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/rebuild", kbID), adminToken, nil)
	if w.Code != http.StatusServiceUnavailable {
		t.Fatalf("rebuild without ingester: want 503, got %d", w.Code)
	}

	// Delete KB
	w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete KB: want 200, got %d", w.Code)
	}

	// Verify deleted
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("get deleted KB: want 404, got %d", w.Code)
	}
}

// ── KB Cross-User Isolation ─────────────────────

func TestIntegration_KBCrossUserIsolation(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	_, userToken := h.registerUser("alice", "alice@test.com", "password123")

	// Admin creates personal KB
	w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Admin Private KB",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("admin create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var adminKB map[string]interface{}
	decode(w, &adminKB)
	adminKBID := adminKB["id"].(string)

	// Alice should NOT see admin's personal KB
	w = h.request("GET", "/api/v1/knowledge-bases", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("alice list KBs: want 200, got %d", w.Code)
	}
	var aliceList map[string]interface{}
	decode(w, &aliceList)
	items := aliceList["data"].([]interface{})
	if len(items) != 0 {
		t.Fatalf("alice should see 0 KBs, got %d", len(items))
	}

	// Alice should NOT be able to access admin's KB directly
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("alice access admin KB: want 404 (hidden), got %d", w.Code)
	}

	// Alice should NOT be able to delete admin's KB
	// Returns 403 (no kb.write permission) or 404 (ownership isolation) — both are correct denials
	w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
	if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
		t.Fatalf("alice delete admin KB: want 403 or 404, got %d", w.Code)
	}
}

// ── Channel KB Linking ──────────────────────────

func TestIntegration_ChannelKBLinking(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create a channel
	w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
		"title": "Test Channel",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var ch map[string]interface{}
	decode(w, &ch)
	channelID := ch["id"].(string)

	// Create a KB
	w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Channel KB",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var kb map[string]interface{}
	decode(w, &kb)
	kbID := kb["id"].(string)

	// Link KB to channel
	w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
		map[string]interface{}{"kb_ids": []string{kbID}})
	if w.Code != http.StatusOK {
		t.Fatalf("link KB to channel: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Get channel KBs
	w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get channel KBs: want 200, got %d", w.Code)
	}
	var linked map[string]interface{}
	decode(w, &linked)
	data := linked["data"].([]interface{})
	if len(data) != 1 {
		t.Fatalf("channel should have 1 linked KB, got %d", len(data))
	}

	// Unlink (set empty)
	w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
		map[string]interface{}{"kb_ids": []string{}})
	if w.Code != http.StatusOK {
		t.Fatalf("unlink KBs: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify empty
	w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
	var empty map[string]interface{}
	decode(w, &empty)
	emptyData := empty["data"].([]interface{})
	if len(emptyData) != 0 {
		t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData))
	}
}

// ── KB Document Status + Delete ─────────────────

func TestIntegration_KB_DocumentStatusAndDelete(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create a KB
	w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Doc Test KB",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var kb map[string]interface{}
	decode(w, &kb)
	kbID := kb["id"].(string)

	// Seed a document directly (upload handler requires object store)
	docID := uuid.NewString()
	_, err := database.TestDB.Exec(dialectSQL(
		`INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`),
		docID, kbID, "test.md", "text/markdown", 1234, "kb/"+kbID+"/"+docID+"_test.md",
		"chunking", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
	if err != nil {
		t.Fatalf("seed doc: %v", err)
	}

	// GET document status
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get doc status: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var status map[string]interface{}
	decode(w, &status)
	if status["id"] != docID {
		t.Fatalf("doc status: id mismatch: got %s, want %s", status["id"], docID)
	}
	if status["status"] != "chunking" {
		t.Fatalf("doc status: want 'chunking', got %s", status["status"])
	}
	if status["filename"] != "test.md" {
		t.Fatalf("doc status: want filename 'test.md', got %s", status["filename"])
	}

	// GET status for wrong KB — should 404
	fakeKB := uuid.NewString()
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", fakeKB, docID), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("doc status wrong KB: want 404, got %d", w.Code)
	}

	// GET status for wrong doc — should 404
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, uuid.NewString()), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("doc status wrong doc: want 404, got %d", w.Code)
	}

	// DELETE document
	w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", kbID, docID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete doc: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var delResp map[string]interface{}
	decode(w, &delResp)
	if delResp["deleted"] != true {
		t.Fatalf("delete doc: want deleted=true, got %v", delResp["deleted"])
	}

	// Verify gone
	w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("deleted doc status: want 404, got %d", w.Code)
	}

	// DELETE on wrong KB — should 404
	docID2 := uuid.NewString()
	database.TestDB.Exec(dialectSQL(
		`INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`),
		docID2, kbID, "other.txt", "text/plain", 100, "placeholder",
		"pending", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))

	w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", fakeKB, docID2), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("delete doc wrong KB: want 404, got %d", w.Code)
	}
}

// ── KB Update Empty Body ────────────────────────

func TestIntegration_KB_UpdateEmptyBody(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create KB
	w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Empty Update KB",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var kb map[string]interface{}
	decode(w, &kb)
	kbID := kb["id"].(string)

	// Update with empty fields — should 400
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken,
		map[string]interface{}{})
	if w.Code != http.StatusBadRequest {
		t.Fatalf("empty update: want 400, got %d: %s", w.Code, w.Body.String())
	}
}

// ── KB Permission Enforcement ───────────────────

func TestIntegration_KB_PermissionEnforcement(t *testing.T) {
	h := setupHarness(t)
	_, _ = h.createAdminUser("admin", "admin@test.com")

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	_, userToken := h.registerUser("normperm", "normperm@test.com", "password123")

	// Regular user without kb.create should get 403
	// (TruncateAll wipes the Everyone group, so no default permissions)
	w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name": "Should Fail",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String())
	}
}

// ══════════════════════════════════════════════
// GROUPS + RESOURCE GRANTS (v0.16.0)
// ══════════════════════════════════════════════

func TestGroupCRUD(t *testing.T) {
	h := setupHarness(t)

	_, adminToken := h.createAdminUser("gadmin", "gadmin@test.com")

	// ── Create global group ──
	w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":        "Engineering",
		"description": "All engineers",
		"scope":       "global",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created models.Group
	decode(w, &created)
	if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" {
		t.Fatalf("unexpected group: %+v", created)
	}

	// ── List groups ──
	w = h.request("GET", "/api/v1/admin/groups", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list groups: want 200, got %d", w.Code)
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	data := listResp["data"].([]interface{})
	if len(data) != 1 {
		t.Fatalf("expected 1 group, got %d", len(data))
	}

	// ── Get group ──
	w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get group: want 200, got %d", w.Code)
	}

	// ── Update group ──
	w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{
		"name": "Platform Engineering",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify update
	w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
	var updated models.Group
	decode(w, &updated)
	if updated.Name != "Platform Engineering" {
		t.Fatalf("name should be updated, got %q", updated.Name)
	}

	// ── Duplicate name conflict ──
	w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":  "Platform Engineering",
		"scope": "global",
	})
	if w.Code != http.StatusConflict {
		t.Fatalf("duplicate name: want 409, got %d", w.Code)
	}

	// ── Delete group ──
	w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete group: want 200, got %d", w.Code)
	}

	// Verify deleted
	w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("deleted group: want 404, got %d", w.Code)
	}
}

func TestGroupMembers(t *testing.T) {
	h := setupHarness(t)

	_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
	userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
	userToken := makeToken(userID, "gmuser@test.com", "user")

	// Create group
	w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":  "Testers",
		"scope": "global",
	})
	var group models.Group
	decode(w, &group)

	// ── Add member ──
	w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
		"user_id": userID,
	})
	if w.Code != http.StatusOK {
		t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── List members ──
	w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list members: want 200, got %d", w.Code)
	}
	var memberResp map[string]interface{}
	decode(w, &memberResp)
	members := memberResp["data"].([]interface{})
	if len(members) != 1 {
		t.Fatalf("expected 1 member, got %d", len(members))
	}
	m := members[0].(map[string]interface{})
	if m["username"] != "gmuser" {
		t.Fatalf("expected username gmuser, got %v", m["username"])
	}

	// ── My groups (user endpoint) ──
	w = h.request("GET", "/api/v1/groups/mine", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("my groups: want 200, got %d", w.Code)
	}
	var myResp map[string]interface{}
	decode(w, &myResp)
	myGroups := myResp["data"].([]interface{})
	if len(myGroups) != 1 {
		t.Fatalf("user should be in 1 group, got %d", len(myGroups))
	}

	// ── Idempotent add (no error) ──
	w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
		"user_id": userID,
	})
	if w.Code != http.StatusOK {
		t.Fatalf("idempotent add: want 200, got %d", w.Code)
	}

	// ── Remove member ──
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify removed
	w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
	decode(w, &memberResp)
	members = memberResp["data"].([]interface{})
	if len(members) != 0 {
		t.Fatalf("expected 0 members after removal, got %d", len(members))
	}
}

func TestGroupScopeValidation(t *testing.T) {
	h := setupHarness(t)

	_, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com")

	// Team-scoped without team_id → 400
	w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":  "Bad Group",
		"scope": "team",
	})
	if w.Code != http.StatusBadRequest {
		t.Fatalf("team scope without team_id: want 400, got %d", w.Code)
	}

	// Global with team_id → 400
	w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":    "Bad Group",
		"scope":   "global",
		"team_id": "00000000-0000-0000-0000-000000000001",
	})
	if w.Code != http.StatusBadRequest {
		t.Fatalf("global scope with team_id: want 400, got %d", w.Code)
	}
}

func TestResourceGrants(t *testing.T) {
	h := setupHarness(t)

	_, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com")

	// Create a group
	w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":  "Grantees",
		"scope": "global",
	})
	var group models.Group
	decode(w, &group)

	// Create a persona (admin)
	w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name":          "Test Bot",
		"base_model_id": "test-model",
		"scope":         "global",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var persona map[string]interface{}
	decode(w, &persona)
	personaID := persona["id"].(string)

	// ── Get grant (no grant yet → default team_only) ──
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get grant: want 200, got %d", w.Code)
	}
	var grantResp map[string]interface{}
	decode(w, &grantResp)
	if grantResp["grant_scope"] != "team_only" {
		t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"])
	}

	// ── Set grant: groups scope ──
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
		"grant_scope":    "groups",
		"granted_groups": []string{group.ID},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── Get grant (now groups) ──
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
	decode(w, &grantResp)
	if grantResp["grant_scope"] != "groups" {
		t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"])
	}

	// ── Set grant: global scope ──
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
		"grant_scope": "global",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("set global grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── Revoke: set back to team_only (deletes grant row) ──
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
		"grant_scope": "team_only",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── Validation: groups scope without groups → 400 ──
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
		"grant_scope": "groups",
	})
	if w.Code != http.StatusBadRequest {
		t.Fatalf("groups without list: want 400, got %d", w.Code)
	}
}

func TestGroupBasedPersonaAccess(t *testing.T) {
	h := setupHarness(t)

	// Setup: admin + regular user (not on any team)
	adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
	userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
	userToken := makeToken(userID, "gpuser@test.com", "user")

	// Create a team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Secret Team",
	})
	var teamResp map[string]interface{}
	decode(w, &teamResp)
	teamID := teamResp["id"].(string)

	// Create persona scoped to that team (user shouldn't see it without group access)
	personaID := seedInsertReturningID(t, `
		INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
		VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
		RETURNING id
	`, teamID, adminID)
	if personaID == "" {
		t.Fatal("personaID is empty after insert")
	}

	// ── User should NOT see team persona ──
	w = h.request("GET", "/api/v1/personas", userToken, nil)
	var personasResp map[string]interface{}
	decode(w, &personasResp)
	personas, _ := personasResp["data"].([]interface{})
	for _, p := range personas {
		pm := p.(map[string]interface{})
		if pm["id"] == personaID {
			t.Fatalf("user should NOT see team persona without group access")
		}
	}

	// ── Create group + add user + grant persona ──
	w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
		"name":  "Secret Readers",
		"scope": "global",
	})
	var group models.Group
	decode(w, &group)

	w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
		"user_id": userID,
	})

	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
		"grant_scope":    "groups",
		"granted_groups": []string{group.ID},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// ── User should NOW see team persona via group grant ──
	w = h.request("GET", "/api/v1/personas", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
	}
	decode(w, &personasResp)
	personas, _ = personasResp["data"].([]interface{})
	found := false
	for _, p := range personas {
		pm := p.(map[string]interface{})
		if pm["id"] == personaID {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("user should see team persona after group grant, but didn't find it in %d personas (response: %s)", len(personas), w.Body.String())
	}

	// ── Remove user from group → should lose access ──
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)

	w = h.request("GET", "/api/v1/personas", userToken, nil)
	decode(w, &personasResp)
	personas, _ = personasResp["data"].([]interface{})
	for _, p := range personas {
		pm := p.(map[string]interface{})
		if pm["id"] == personaID {
			t.Fatalf("user should NOT see persona after group removal")
		}
	}
}

// ═══════════════════════════════════════════════════════════════
// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
// ═══════════════════════════════════════════════════════════════

// ── Persona-KB Binding CRUD ──

func TestIntegration_PersonaKB_Binding(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// 1. Create a global persona via admin API
	w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name":          "KB Persona",
		"base_model_id": "test-model",
		"system_prompt": "You are a test persona.",
	})
	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
	}
	var personaResp map[string]interface{}
	decode(w, &personaResp)
	personaID := personaResp["id"].(string)

	// 2. Create a global KB
	w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name":  "Test KB",
		"scope": "global",
	})
	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
	}
	var kbResp map[string]interface{}
	decode(w, &kbResp)
	kbID := kbResp["id"].(string)

	// 3. Bind KB to persona
	w = h.request("PUT",
		fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
		adminToken, map[string]interface{}{
			"kb_ids":      []string{kbID},
			"auto_search": map[string]bool{kbID: true},
		})
	if w.Code != http.StatusOK {
		t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// 4. Read back bindings
	w = h.request("GET",
		fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
		adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	data := listResp["data"].([]interface{})
	if len(data) != 1 {
		t.Fatalf("expected 1 binding, got %d", len(data))
	}
	binding := data[0].(map[string]interface{})
	if binding["kb_id"] != kbID {
		t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
	}
	if binding["auto_search"] != true {
		t.Fatal("auto_search should be true")
	}

	// 5. Unbind — send empty list
	w = h.request("PUT",
		fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
		adminToken, map[string]interface{}{
			"kb_ids": []string{},
		})
	if w.Code != http.StatusOK {
		t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// 6. Verify empty
	w = h.request("GET",
		fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
		adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var emptyResp map[string]interface{}
	decode(w, &emptyResp)
	emptyData := emptyResp["data"].([]interface{})
	if len(emptyData) != 0 {
		t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
	}
}

// ── KB Create Auth (was TODO — now enforced) ──

func TestIntegration_KB_CreateAuth(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	_ = adminToken

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	// Seed Everyone group with kb.create so the permission middleware passes
	// and we can test the handler-level scope checks below.
	_, err := database.TestDB.Exec(dialectSQL(`
		INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
		VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
		"00000000-0000-0000-0000-000000000001", "Everyone",
		"Implicit group — all authenticated users receive these permissions.",
		"global", "system", `["model.use","kb.read","kb.create","channel.create"]`)
	if err != nil {
		t.Fatalf("seed Everyone group: %v", err)
	}

	_, userToken := h.registerUser("kbuser", "kb@test.com", "password123")

	// Regular user should NOT be able to create global KBs
	w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name":  "Sneaky Global KB",
		"scope": "global",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Regular user should NOT be able to create team KBs without team admin role
	w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name":    "Sneaky Team KB",
		"scope":   "team",
		"team_id": "00000000-0000-0000-0000-000000000001",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Regular user CAN create personal KBs
	w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name":  "My Personal KB",
		"scope": "personal",
	})
	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
	}
}

// ── KB Team Scope Creation ──────────────────────

func TestIntegration_KB_TeamScopeCreate(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	// Seed Everyone group with kb.create
	database.TestDB.Exec(dialectSQL(`
		INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
		VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
		"00000000-0000-0000-0000-000000000001", "Everyone",
		"Implicit group", "global", "system",
		`["model.use","kb.read","kb.create","channel.create"]`)

	userID, userToken := h.registerUser("teamkb", "teamkb@test.com", "password123")

	// Create a team via admin
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "KB Team", "description": "team for KB test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Add user as regular member — should NOT be able to create team KB
	w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": userID, "role": "member"})
	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String())
	}

	w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name": "Team KB by Member", "scope": "team", "team_id": teamID,
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("member create team KB: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Promote to team admin — should succeed
	database.TestDB.Exec(dialectSQL(
		`UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2`),
		teamID, userID)

	w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
		"name": "Team KB by Admin", "scope": "team", "team_id": teamID,
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("team admin create team KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var kbResp map[string]interface{}
	decode(w, &kbResp)
	if kbResp["scope"] != "team" {
		t.Fatalf("team KB scope: want 'team', got %s", kbResp["scope"])
	}
	if kbResp["team_id"] != teamID {
		t.Fatalf("team KB team_id: want %s, got %s", teamID, kbResp["team_id"])
	}
}

// ── KB Discoverable Flag ──

func TestIntegration_KB_Discoverable(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	_, userToken := h.registerUser("discuser", "disc@test.com", "password123")

	// 1. Admin creates a global KB (discoverable=true by default)
	w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name":  "Visible KB",
		"scope": "global",
	})
	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
	}
	var kbResp map[string]interface{}
	decode(w, &kbResp)
	kbID := kbResp["id"].(string)

	// 2. User can see it in discoverable list
	w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var discovResp map[string]interface{}
	decode(w, &discovResp)
	kbs, _ := discovResp["data"].([]interface{})
	if kbs == nil {
		t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
	}
	found := false
	for _, kb := range kbs {
		kbMap := kb.(map[string]interface{})
		if kbMap["id"] == kbID {
			found = true
			// Verify kbResponse shape (not raw model)
			if _, ok := kbMap["status"]; !ok {
				t.Fatal("discoverable response missing 'status' field (not using kbResponse?)")
			}
			if _, ok := kbMap["chunk_count"]; !ok {
				t.Fatal("discoverable response missing 'chunk_count' field (not using kbResponse?)")
			}
			// created_at should be ISO format (kbResponse), not Go default with nanoseconds
			ts, _ := kbMap["created_at"].(string)
			if len(ts) > 0 && strings.Contains(ts, ".") {
				t.Fatalf("discoverable created_at has nanoseconds (raw model leak): %s", ts)
			}
			break
		}
	}
	if !found {
		t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
	}

	// 3. Admin hides the KB
	w = h.request("PUT",
		fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
		adminToken, map[string]interface{}{"discoverable": false})
	if w.Code != http.StatusOK {
		t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// 4. User can no longer see it
	w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var discovResp2 map[string]interface{}
	decode(w, &discovResp2)
	kbs2, _ := discovResp2["data"].([]interface{})
	for _, kb := range kbs2 {
		if kb.(map[string]interface{})["id"] == kbID {
			t.Fatal("hidden KB should not appear in discoverable list")
		}
	}
}

// ── Platform Policy: kb_direct_access seeded ──

func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Verify the migration seeded the policy by reading it through admin settings
	w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get public settings: want 200, got %d", w.Code)
	}
	// Policy existence is verified by the migration running without error
	// (if kb_direct_access INSERT fails, the migration fails and no tests run)
	_ = w
}

// ── KB SetDiscoverable Auth Enforcement ─────────

func TestIntegration_KB_SetDiscoverableAuth(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
	_ = adminID

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	_, aliceToken := h.registerUser("alice", "alice@test.com", "password123")
	_, bobToken := h.registerUser("bob", "bob@test.com", "password123")

	// Seed Everyone group with kb.create so users can create KBs
	database.TestDB.Exec(dialectSQL(`
		INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
		VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
		"00000000-0000-0000-0000-000000000001", "Everyone",
		"Implicit group", "global", "system",
		`["model.use","kb.read","kb.create","channel.create"]`)

	// Alice creates a personal KB
	w := h.request("POST", "/api/v1/knowledge-bases", aliceToken, map[string]interface{}{
		"name": "Alice KB",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("alice create KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var aliceKB map[string]interface{}
	decode(w, &aliceKB)
	aliceKBID := aliceKB["id"].(string)

	// Alice CAN toggle her own KB
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
		aliceToken, map[string]interface{}{"discoverable": false})
	if w.Code != http.StatusOK {
		t.Fatalf("owner toggle discoverable: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Bob CANNOT toggle Alice's KB (not owner, not admin)
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
		bobToken, map[string]interface{}{"discoverable": true})
	if w.Code != http.StatusNotFound {
		// loadAndAuthorize returns 404 for personal KBs the user doesn't own
		t.Fatalf("non-owner toggle discoverable: want 404, got %d: %s", w.Code, w.Body.String())
	}

	// Admin creates a global KB
	w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
		"name": "Global KB", "scope": "global",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("admin create global KB: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var globalKB map[string]interface{}
	decode(w, &globalKB)
	globalKBID := globalKB["id"].(string)

	// Alice CANNOT toggle a global KB she doesn't own
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID),
		aliceToken, map[string]interface{}{"discoverable": false})
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-admin toggle global discoverable: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Admin CAN toggle the global KB
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID),
		adminToken, map[string]interface{}{"discoverable": false})
	if w.Code != http.StatusOK {
		t.Fatalf("admin toggle global discoverable: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Admin CANNOT access Alice's personal KB (loadAndAuthorize returns 404
	// for personal KBs not owned by the caller — consistent with Get/Update/Delete)
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
		adminToken, map[string]interface{}{"discoverable": true})
	if w.Code != http.StatusNotFound {
		t.Fatalf("admin toggle alice personal KB: want 404 (not owner), got %d: %s", w.Code, w.Body.String())
	}

	// Non-existent KB returns 404
	w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", uuid.NewString()),
		adminToken, map[string]interface{}{"discoverable": false})
	if w.Code != http.StatusNotFound {
		t.Fatalf("toggle nonexistent KB: want 404, got %d: %s", w.Code, w.Body.String())
	}
}

// ═══════════════════════════════════════════════
// Messages + Treepath tests (SQLite compat)
// ═══════════════════════════════════════════════

func TestIntegration_Messages_CRUD(t *testing.T) {
	h := setupHarness(t)
	_, token := h.createAdminUser("msguser", "msg@test.com")

	// Create channel
	w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
		"title": "Message Test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var ch map[string]interface{}
	decode(w, &ch)
	channelID := ch["id"].(string)

	// Create first message
	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role":    "user",
		"content": "Hello, world!",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create message: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var msg1 map[string]interface{}
	decode(w, &msg1)
	if msg1["id"] == nil || msg1["id"].(string) == "" {
		t.Fatal("message should have an id")
	}
	if msg1["content"].(string) != "Hello, world!" {
		t.Fatalf("content mismatch: got %q", msg1["content"])
	}
	msg1ID := msg1["id"].(string)

	// Create second message (child of first)
	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role":      "assistant",
		"content":   "Hi there!",
		"parent_id": msg1ID,
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create message 2: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// List messages — should have 2
	w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list messages: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	total := int(listResp["total"].(float64))
	if total != 2 {
		t.Fatalf("expected 2 messages, got %d", total)
	}
}

func TestIntegration_Messages_EditFork(t *testing.T) {
	h := setupHarness(t)
	_, token := h.createAdminUser("forkuser", "fork@test.com")

	// Create channel
	w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
		"title": "Fork Test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
	}
	var ch map[string]interface{}
	decode(w, &ch)
	channelID := ch["id"].(string)

	// Create user message (root)
	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role":    "user",
		"content": "First draft",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create message: %d: %s", w.Code, w.Body.String())
	}
	var msg map[string]interface{}
	decode(w, &msg)
	msgID := msg["id"].(string)

	// Edit (fork) the message — creates a sibling
	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages/%s/edit", channelID, msgID), token, map[string]interface{}{
		"content": "Second draft",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("edit message: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var edited map[string]interface{}
	decode(w, &edited)
	if edited["content"].(string) != "Second draft" {
		t.Fatalf("edited content mismatch: got %q", edited["content"])
	}

	// Sibling count should be 2 (original + edit)
	sibCount := int(edited["sibling_count"].(float64))
	if sibCount != 2 {
		t.Fatalf("expected sibling_count=2, got %d", sibCount)
	}

	// Verify via siblings endpoint
	w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages/%s/siblings", channelID, msgID), token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list siblings: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var sibResp map[string]interface{}
	decode(w, &sibResp)
	siblings := sibResp["siblings"].([]interface{})
	if len(siblings) != 2 {
		t.Fatalf("expected 2 siblings, got %d", len(siblings))
	}
}

func TestIntegration_Messages_TreePath(t *testing.T) {
	h := setupHarness(t)
	_, token := h.createAdminUser("pathuser", "path@test.com")

	// Create channel
	w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
		"title": "Path Test",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
	}
	var ch map[string]interface{}
	decode(w, &ch)
	channelID := ch["id"].(string)

	// Build a 3-message chain: root → child → grandchild
	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role": "user", "content": "root message",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("msg1: %d: %s", w.Code, w.Body.String())
	}
	var m1 map[string]interface{}
	decode(w, &m1)

	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role": "assistant", "content": "response", "parent_id": m1["id"].(string),
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("msg2: %d: %s", w.Code, w.Body.String())
	}
	var m2 map[string]interface{}
	decode(w, &m2)

	w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
		"role": "user", "content": "follow-up", "parent_id": m2["id"].(string),
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("msg3: %d: %s", w.Code, w.Body.String())
	}

	// Get active path — should return 3 messages in root-first order
	w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/path", channelID), token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get path: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var pathEnv map[string]interface{}
	decode(w, &pathEnv)
	pathResp := pathEnv["data"].([]interface{})
	if len(pathResp) != 3 {
		t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
	}

	// Verify order: root first, grandchild last
	first := pathResp[0].(map[string]interface{})
	last := pathResp[2].(map[string]interface{})
	if first["content"].(string) != "root message" {
		t.Fatalf("first in path should be root, got %q", first["content"])
	}
	if last["content"].(string) != "follow-up" {
		t.Fatalf("last in path should be follow-up, got %q", last["content"])
	}
}

// ── Channel List with Type Filter (regression: $1 reuse in SQLite) ──

func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
	h := setupHarness(t)
	_, token := h.createAdminUser("admin", "admin@test.com")

	// Create a direct chat
	w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
		"title": "Test Direct Chat",
		"type":  "direct",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// List without type filter — should succeed
	w = h.request("GET", "/api/v1/channels?page=1&per_page=100", token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list channels (no filter): want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List with single type filter
	w = h.request("GET", "/api/v1/channels?page=1&per_page=100&type=direct", token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list channels (type=direct): want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List with multi-value types filter — this triggers the $1 reuse in the
	// count subquery. Before the QArgs fix, this returned 500 on SQLite.
	w = h.request("GET", "/api/v1/channels?page=1&per_page=100&types=direct,group", token, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list channels (types=direct,group): want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify the response is valid
	var resp map[string]interface{}
	decode(w, &resp)
	// The response may use "channels" or "data" depending on the handler.
	// Verify the request succeeded (200) — that's the regression test.
	// Channel content verification is covered by other tests.
}

// ═══════════════════════════════════════════
//  TEAMS ICD AUDIT — Phase 2 Tests
// ═══════════════════════════════════════════

// ── Admin Team CRUD Lifecycle ──────────────

func TestAudit_AdminTeamCRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// ── Create ──
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Audit Team", "description": "For audit testing",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	teamID := created["id"].(string)
	if created["name"] != "Audit Team" {
		t.Fatalf("create response should have name, got %v", created["name"])
	}

	// ── List (data envelope) ──
	w = h.request("GET", "/api/v1/admin/teams", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list teams: want 200, got %d", w.Code)
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	if _, ok := listResp["data"]; !ok {
		t.Fatal("GET /admin/teams must return 'data' key (envelope convention)")
	}
	if _, ok := listResp["total"]; !ok {
		t.Fatal("GET /admin/teams must return 'total' key (paginated)")
	}

	// ── Get ──
	w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var got map[string]interface{}
	decode(w, &got)
	if got["name"] != "Audit Team" {
		t.Fatalf("get team name mismatch: %v", got["name"])
	}

	// ── Get nonexistent ──
	w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("get nonexistent team: want 404, got %d", w.Code)
	}

	// ── Update ──
	w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
		"name": "Renamed Team",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify update
	w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
	decode(w, &got)
	if got["name"] != "Renamed Team" {
		t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"])
	}

	// ── Update with no fields → 400 ──
	w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{})
	if w.Code != http.StatusBadRequest {
		t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String())
	}

	// ── Duplicate name → 409 ──
	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "Second Team",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create second team: %d: %s", w.Code, w.Body.String())
	}
	w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
		"name": "Second Team",
	})
	if w.Code != http.StatusConflict {
		t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String())
	}

	// ── Delete ──
	w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify deleted
	w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("deleted team: want 404, got %d", w.Code)
	}

	// ── Delete nonexistent → 404 ──
	w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("delete nonexistent: want 404, got %d", w.Code)
	}
}

// ── Admin Member Update + Remove ───────────

func TestAudit_AdminMemberUpdateRemove(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	userID := database.SeedTestUser(t, "bob", "bob@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)

	// Create team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "MemberTest",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Add member
	w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": userID, "role": "member"})
	if w.Code != http.StatusCreated {
		t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// Get member ID from listing
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
	var membersResp map[string]interface{}
	decode(w, &membersResp)
	members := membersResp["data"].([]interface{})
	if len(members) != 1 {
		t.Fatalf("expected 1 member, got %d", len(members))
	}
	memberID := members[0].(map[string]interface{})["id"].(string)

	// Update role → admin
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken,
		map[string]string{"role": "admin"})
	if w.Code != http.StatusOK {
		t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Update nonexistent member → 404
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken,
		map[string]string{"role": "member"})
	if w.Code != http.StatusNotFound {
		t.Fatalf("update nonexistent member: want 404, got %d", w.Code)
	}

	// Remove member
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Remove nonexistent → 404
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("remove nonexistent: want 404, got %d", w.Code)
	}
}

// ── C1: Cross-Team Member Mutation ─────────
// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint.
// A team admin for Team A can mutate membership in Team B by knowing
// the team_members.id primary key.

func TestAudit_CrossTeamMemberMutation(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create two users
	aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
	bobID := database.SeedTestUser(t, "bob", "bob@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)

	// Create Team A and Team B
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"})
	var tA map[string]interface{}
	decode(w, &tA)
	teamAID := tA["id"].(string)

	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"})
	var tB map[string]interface{}
	decode(w, &tB)
	teamBID := tB["id"].(string)

	// Alice is admin of Team A
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken,
		map[string]string{"user_id": aliceID, "role": "admin"})

	// Bob is member of Team B
	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken,
		map[string]string{"user_id": bobID, "role": "member"})

	// Get Bob's team_members.id from Team B listing
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil)
	var bMembers map[string]interface{}
	decode(w, &bMembers)
	bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string)

	// Alice (Team A admin) tries to update Bob's role in Team B
	// via the team-scoped route for Team A — should fail with 404 because
	// bobMemberID does not belong to Team A.
	aliceToken := makeToken(aliceID, "alice@test.com", "user")
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken,
		map[string]string{"role": "admin"})
	if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
		t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+
			"UpdateMember WHERE clause has no team_id constraint — authorization bypass",
			w.Code, w.Body.String())
	}

	// Alice (Team A admin) tries to remove Bob from Team B
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil)
	if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
		t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+
			"RemoveMember WHERE clause has no team_id constraint — authorization bypass",
			w.Code, w.Body.String())
	}
}

// ── Team Audit Log ─────────────────────────

func TestAudit_TeamAuditLog(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create team + add admin as team member so audit actions are scoped
	teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")

	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "AuditTeam",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})

	// ── Query audit log (team admin) ──
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var auditResp map[string]interface{}
	decode(w, &auditResp)
	// Must use "data" envelope
	if _, ok := auditResp["data"]; !ok {
		t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)")
	}
	if _, ok := auditResp["total"]; !ok {
		t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)")
	}

	// ── Query audit actions ──
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var actionsResp map[string]interface{}
	decode(w, &actionsResp)
	if _, ok := actionsResp["actions"]; !ok {
		t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key")
	}
}

// ── Admin Permissions Listing ──────────────

func TestAudit_AdminPermissions(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// ── List all permissions ──
	w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var permResp map[string]interface{}
	decode(w, &permResp)
	perms, ok := permResp["permissions"]
	if !ok {
		t.Fatal("GET /admin/permissions must return 'permissions' key")
	}
	permList := perms.([]interface{})
	if len(permList) == 0 {
		t.Fatal("permissions list should not be empty")
	}

	// Verify known permissions are present
	found := false
	for _, p := range permList {
		if p.(string) == "model.use" {
			found = true
			break
		}
	}
	if !found {
		t.Fatal("permissions list should contain 'model.use'")
	}
}

// ── User Effective Permissions ─────────────

func TestAudit_UserEffectivePermissions(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("admin", "admin@test.com")

	userID := database.SeedTestUser(t, "charlie", "charlie@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)

	// ── Get permissions for user ──
	w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	if _, ok := resp["permissions"]; !ok {
		t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key")
	}
	if _, ok := resp["user_id"]; !ok {
		t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key")
	}
	// H10: must include contributing groups
	groups, ok := resp["groups"]
	if !ok {
		t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)")
	}
	groupList := groups.([]interface{})
	// Should always contain the Everyone group
	found := false
	for _, g := range groupList {
		if g.(string) == "00000000-0000-0000-0000-000000000001" {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("groups should always include the Everyone group, got %v", groupList)
	}

	// ── Get permissions for admin (should include their own set) ──
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get admin permissions: want 200, got %d", w.Code)
	}
}

// ── System Group Deletion Rejection ────────

func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	everyoneID := "00000000-0000-0000-0000-000000000001"

	// Seed the Everyone group (TruncateAll wipes migration-seeded data)
	_, err := database.TestDB.Exec(dialectSQL(`
		INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
		VALUES ($1, $2, $3, $4, NULL, $5, $6)
	`), everyoneID, "Everyone",
		"Implicit group — all authenticated users receive these permissions.",
		"global", "system", `["model.use","kb.read","channel.create"]`)
	if err != nil {
		t.Fatalf("seed Everyone group: %v", err)
	}

	// Attempt to delete the Everyone system group → should fail with 400
	w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("delete system group: want 400, got %d: %s\n"+
			"System groups (source=system) must be protected from deletion",
			w.Code, w.Body.String())
	}
}

// ── Resource Grant DELETE Round-Trip ────────

func TestAudit_ResourceGrantDelete(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	// Create a persona to grant
	w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name":          "Grant Test Persona",
		"base_model_id": "test-model",
		"scope":         "global",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var persona map[string]interface{}
	decode(w, &persona)
	personaID := persona["id"].(string)

	// Set a grant
	w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken,
		map[string]interface{}{
			"grant_scope": "global",
		})
	if w.Code != http.StatusOK {
		t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify the grant exists
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get grant: want 200, got %d", w.Code)
	}
	var grantResp map[string]interface{}
	decode(w, &grantResp)
	if grantResp["grant_scope"] != "global" {
		t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"])
	}

	// DELETE the grant
	w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// After delete, GET should return default (team_only)
	w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get after delete: want 200, got %d", w.Code)
	}
	decode(w, &grantResp)
	if grantResp["grant_scope"] != "team_only" {
		t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"])
	}
}

// ── H8: Team Providers Envelope ────────────
// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}.

func TestAudit_TeamProvidersEnvelope(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")

	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "ProvEnvTeam",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})

	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)

	// Must use "data" not "providers"
	if _, ok := resp["data"]; !ok {
		t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+
			"got keys: %v", mapKeys(resp))
	}
	if _, ok := resp["allow_team_providers"]; !ok {
		t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'")
	}
}

// ── H9: Team Roles Envelope ────────────────
// Roles endpoints return composite named-key maps (not lists).
// Convention: composite endpoints return object directly, no {"data": ...} wrapper.
func TestAudit_TeamRolesEnvelope(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")

	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "RolesEnvTeam",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})

	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)

	// Composite endpoint — must NOT have a "data" wrapper
	if _, ok := resp["data"]; ok {
		t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+
			"not wrapped in {\"data\": ...}")
	}
}

// ── Team Groups Listing ────────────────────

func TestAudit_TeamGroupsListing(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("admin", "admin@test.com")

	teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
	teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")

	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
		"name": "GroupsTeam",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
		map[string]string{"user_id": teamAdminID, "role": "admin"})

	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)

	// Must use "data" envelope
	if _, ok := resp["data"]; !ok {
		t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp))
	}
}

// ── Helper: map keys for diagnostic output ──

func mapKeys(m map[string]interface{}) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

// ═══════════════════════════════════════════════════════════════
// PERSONA AUDIT TESTS (v0.28.0-audit CS2)
// ═══════════════════════════════════════════════════════════════

// ── Persona CRUD ────────────────────────────

func TestPersona_CRUD_Personal(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com")
	_ = adminID

	// Enable persona creation policy
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	// Create
	w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
		"name": "My Helper", "base_model_id": "test-model",
		"system_prompt": "You are helpful.", "description": "Test persona",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)
	if created["handle"] == nil || created["handle"] == "" {
		t.Error("handle should be auto-generated from name")
	}

	// Update
	w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{
		"name": "My Updated Helper", "description": "Updated desc",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List — verify updated name appears
	w = h.request("GET", "/api/v1/personas", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	data := listResp["data"].([]interface{})
	found := false
	for _, p := range data {
		pm := p.(map[string]interface{})
		if pm["id"] == personaID && pm["name"] == "My Updated Helper" {
			found = true
		}
	}
	if !found {
		t.Error("updated persona not found in list with new name")
	}

	// Delete
	w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

func TestPersona_Update_OwnershipCheck(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("own1", "own1@test.com")
	otherID := database.SeedTestUser(t, "own2", "own2@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
	otherToken := makeToken(otherID, "own2@test.com", "user")

	// Enable persona creation
	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	// Admin creates a personal persona
	w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
		"name": "Admin's Bot", "base_model_id": "test-model",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)

	// Other user tries to update — should be forbidden
	w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{
		"name": "Stolen",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Other user tries to delete — should be forbidden
	w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
	}
}

func TestPersona_AdminCRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("apadmin", "apadmin@test.com")

	// Create global persona via admin
	w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name": "Global Bot", "base_model_id": "test-model",
		"system_prompt": "You are global.",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)
	if created["scope"] != "global" {
		t.Fatalf("admin persona scope: want 'global', got %v", created["scope"])
	}

	// Update
	w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{
		"name": "Global Bot v2",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List
	w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	if listResp["data"] == nil {
		t.Fatal("admin list should return 'data' key")
	}

	// Delete
	w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── Envelope + Nil Slice ────────────────────

func TestPersona_EnvelopeKey(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("envadm", "envadm@test.com")

	// User list
	w := h.request("GET", "/api/v1/personas", adminToken, nil)
	var resp map[string]interface{}
	decode(w, &resp)
	if _, ok := resp["personas"]; ok {
		t.Error("response should use 'data' key, not 'personas'")
	}
	if resp["data"] == nil {
		t.Error("response should have 'data' key")
	}

	// Admin list
	w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
	decode(w, &resp)
	if _, ok := resp["personas"]; ok {
		t.Error("admin response should use 'data' key, not 'personas'")
	}
	if resp["data"] == nil {
		t.Error("admin response should have 'data' key")
	}
}

func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("niladm", "niladm@test.com")

	// Fresh user, no personas — should get [] not null
	w := h.request("GET", "/api/v1/personas", adminToken, nil)
	// Check raw JSON contains [] not null
	body := w.Body.String()
	if strings.Contains(body, `"data":null`) {
		t.Fatal("data should be [] not null for empty persona list")
	}
	if !strings.Contains(body, `"data":[]`) {
		t.Fatalf("data should be empty array, got: %s", body)
	}

	// Admin list
	w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
	body = w.Body.String()
	if strings.Contains(body, `"data":null`) {
		t.Fatal("admin data should be [] not null for empty persona list")
	}
}

// ── Tool Grants ─────────────────────────────

func TestPersona_ToolGrants_RoundTrip(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("tgadm", "tgadm@test.com")

	// Create persona
	w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name": "Grant Bot", "base_model_id": "test-model",
	})
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)

	// Initially empty
	w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var grantResp map[string]interface{}
	decode(w, &grantResp)
	grants := grantResp["data"].([]interface{})
	if len(grants) != 0 {
		t.Fatalf("initial grants: want 0, got %d", len(grants))
	}

	// Set grants
	w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
		"tool_names": []string{"web_search", "calculator", "kb_search"},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Read back
	w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
	decode(w, &grantResp)
	grants = grantResp["data"].([]interface{})
	if len(grants) != 3 {
		t.Fatalf("grants after set: want 3, got %d", len(grants))
	}

	// Clear grants
	w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
		"tool_names": []string{},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Read back — should be empty
	w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
	decode(w, &grantResp)
	grants = grantResp["data"].([]interface{})
	if len(grants) != 0 {
		t.Fatalf("grants after clear: want 0, got %d", len(grants))
	}

	// Nil-slice check: verify "data":[] not "data":null
	body := w.Body.String()
	if strings.Contains(body, `"data":null`) {
		t.Fatal("tool grants should return [] not null when empty")
	}
}

func TestPersona_ToolGrants_UserScope(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("tguadm", "tguadm@test.com")

	database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")

	// Create personal persona
	w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
		"name": "User Grant Bot", "base_model_id": "test-model",
	})
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)

	// Set via user endpoint
	w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
		"tool_names": []string{"web_search"},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Read back via user endpoint
	w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var grantResp map[string]interface{}
	decode(w, &grantResp)
	grants := grantResp["data"].([]interface{})
	if len(grants) != 1 {
		t.Fatalf("user grants: want 1, got %d", len(grants))
	}
}

// ── Team Persona CRUD + Scope Checks ────────

func TestTeamPersona_CRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("tpadm", "tpadm@test.com")

	// Create a team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Persona Team",
	})
	var teamResp map[string]interface{}
	decode(w, &teamResp)
	teamID := teamResp["id"].(string)

	// Create team persona
	w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
		"name": "Team Bot", "base_model_id": "test-model",
		"system_prompt": "You are a team bot.",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)
	if created["scope"] != "team" {
		t.Fatalf("team persona scope: want 'team', got %v", created["scope"])
	}

	// List team personas
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	if listResp["data"] == nil {
		t.Fatal("team persona list should use 'data' key")
	}
	items := listResp["data"].([]interface{})
	if len(items) != 1 {
		t.Fatalf("team personas: want 1, got %d", len(items))
	}

	// Update team persona
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{
		"name": "Team Bot v2",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Delete team persona
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify deleted
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
	decode(w, &listResp)
	items = listResp["data"].([]interface{})
	if len(items) != 0 {
		t.Fatalf("team personas after delete: want 0, got %d", len(items))
	}
}

func TestTeamPersona_Delete_ScopeCheck(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com")

	// Create two teams
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Team Alpha",
	})
	var teamA map[string]interface{}
	decode(w, &teamA)
	teamAID := teamA["id"].(string)

	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Team Beta",
	})
	var teamB map[string]interface{}
	decode(w, &teamB)
	teamBID := teamB["id"].(string)

	// Create persona on Team Alpha
	personaID := seedInsertReturningID(t, `
		INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
		VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true)
		RETURNING id
	`, teamAID, adminID)

	// Try to delete it via Team Beta route — should fail
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Try to update it via Team Beta route — should fail
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{
		"name": "Stolen",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Delete via correct team — should succeed
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

func TestTeamPersona_KB_ScopeCheck(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com")

	// Create two teams
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "KB Team A",
	})
	var teamA map[string]interface{}
	decode(w, &teamA)
	teamAID := teamA["id"].(string)

	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "KB Team B",
	})
	var teamB map[string]interface{}
	decode(w, &teamB)
	teamBID := teamB["id"].(string)

	// Create persona on Team A
	personaID := seedInsertReturningID(t, `
		INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
		VALUES ('KB Bot', 'test-model', 'team', $1, $2, true)
		RETURNING id
	`, teamAID, adminID)

	// Try to read KBs via Team B — should fail
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Try to set KBs via Team B — should fail
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{
		"kb_ids": []string{},
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Read via correct team — should succeed
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── Persona Groups ──────────────────────────

func TestPersonaGroup_CRUD(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("pgadm", "pgadm@test.com")

	// Create group
	w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
		"name": "Support Team", "description": "Front-line support personas",
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
	}
	var created map[string]interface{}
	decode(w, &created)
	groupID := created["id"].(string)
	if created["scope"] != "personal" {
		t.Fatalf("group scope: want 'personal', got %v", created["scope"])
	}

	// Get
	w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var fetched map[string]interface{}
	decode(w, &fetched)
	if fetched["name"] != "Support Team" {
		t.Fatalf("group name: want 'Support Team', got %v", fetched["name"])
	}

	// Update
	w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{
		"name": "Support Team v2",
	})
	if w.Code != http.StatusOK {
		t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// List
	w = h.request("GET", "/api/v1/persona-groups", adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var listResp map[string]interface{}
	decode(w, &listResp)
	if listResp["data"] == nil {
		t.Fatal("group list should use 'data' key")
	}
	if _, ok := listResp["groups"]; ok {
		t.Error("group list should NOT use 'groups' key")
	}

	// Delete
	w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify deleted
	w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("get deleted group: want 404, got %d", w.Code)
	}
}

func TestPersonaGroup_Members(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com")

	// Create a persona for the group
	w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name": "Group Persona A", "base_model_id": "test-model",
	})
	var pA map[string]interface{}
	decode(w, &pA)
	personaAID := pA["id"].(string)

	w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
		"name": "Group Persona B", "base_model_id": "test-model",
	})
	var pB map[string]interface{}
	decode(w, &pB)
	personaBID := pB["id"].(string)

	// Create group
	w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
		"name": "Two-Bot Team",
	})
	var group map[string]interface{}
	decode(w, &group)
	groupID := group["id"].(string)

	// Add member A as leader
	w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
		"persona_id": personaAID, "is_leader": true,
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// Add member B
	w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
		"persona_id": personaBID,
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// Get group — should have 2 members
	w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var fetched map[string]interface{}
	decode(w, &fetched)
	members := fetched["members"].([]interface{})
	if len(members) != 2 {
		t.Fatalf("members: want 2, got %d", len(members))
	}

	// Verify leader is first (sorted by is_leader DESC)
	first := members[0].(map[string]interface{})
	if first["is_leader"] != true {
		t.Error("first member should be the leader")
	}

	// Swap leader to B — should clear A's leader flag
	w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
		"persona_id": personaBID, "is_leader": true,
	})
	if w.Code != http.StatusCreated {
		t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String())
	}

	// Get group — verify only one leader
	w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	decode(w, &fetched)
	members = fetched["members"].([]interface{})
	leaderCount := 0
	for _, m := range members {
		mm := m.(map[string]interface{})
		if mm["is_leader"] == true {
			leaderCount++
		}
	}
	if leaderCount != 1 {
		t.Fatalf("leaders after swap: want 1, got %d", leaderCount)
	}

	// Remove member A
	memberAID := ""
	for _, m := range members {
		mm := m.(map[string]interface{})
		if mm["persona_id"] == personaAID {
			memberAID = mm["id"].(string)
		}
	}
	if memberAID == "" {
		t.Fatal("could not find member A in group")
	}
	w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Get group — should have 1 member
	w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
	decode(w, &fetched)
	members = fetched["members"].([]interface{})
	if len(members) != 1 {
		t.Fatalf("members after remove: want 1, got %d", len(members))
	}
}

func TestPersonaGroup_OwnershipCheck(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("pgown1", "pgown1@test.com")
	otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com")
	database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
	otherToken := makeToken(otherID, "pgown2@test.com", "user")

	// Admin creates a group
	w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
		"name": "Admin's Group",
	})
	var group map[string]interface{}
	decode(w, &group)
	groupID := group["id"].(string)

	// Other user tries to update — should fail
	w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{
		"name": "Stolen Group",
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Other user tries to delete — should fail
	w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String())
	}

	// Other user list — should not see admin's group
	w = h.request("GET", "/api/v1/persona-groups", otherToken, nil)
	var listResp map[string]interface{}
	decode(w, &listResp)
	items := listResp["data"].([]interface{})
	if len(items) != 0 {
		t.Fatalf("non-owner should see 0 groups, got %d", len(items))
	}
}

func TestPersonaGroup_EnvelopeKey(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com")

	w := h.request("GET", "/api/v1/persona-groups", adminToken, nil)
	var resp map[string]interface{}
	decode(w, &resp)

	if _, ok := resp["groups"]; ok {
		t.Error("persona-groups list should use 'data' key, not 'groups'")
	}
	if resp["data"] == nil {
		t.Error("persona-groups list should have 'data' key")
	}
}

// ── Team Persona Tool Grants ────────────────

func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) {
	h := setupHarness(t)
	_, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com")

	// Create team
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "ToolGrant Team",
	})
	var team map[string]interface{}
	decode(w, &team)
	teamID := team["id"].(string)

	// Create team persona
	w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
		"name": "TG Bot", "base_model_id": "test-model",
	})
	var created map[string]interface{}
	decode(w, &created)
	personaID := created["id"].(string)

	// Initially empty
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}
	var resp map[string]interface{}
	decode(w, &resp)
	grants := resp["data"].([]interface{})
	if len(grants) != 0 {
		t.Fatalf("initial team grants: want 0, got %d", len(grants))
	}

	// Set
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{
		"tool_names": []string{"web_search", "kb_search"},
	})
	if w.Code != http.StatusOK {
		t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}

	// Read back
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
	decode(w, &resp)
	grants = resp["data"].([]interface{})
	if len(grants) != 2 {
		t.Fatalf("team grants after set: want 2, got %d", len(grants))
	}
}

func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com")

	// Create two teams
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "TG Scope A",
	})
	var teamA map[string]interface{}
	decode(w, &teamA)
	teamAID := teamA["id"].(string)

	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "TG Scope B",
	})
	var teamB map[string]interface{}
	decode(w, &teamB)
	teamBID := teamB["id"].(string)

	// Create persona on Team A
	personaID := seedInsertReturningID(t, `
		INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
		VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true)
		RETURNING id
	`, teamAID, adminID)

	// Try to read via Team B — 403
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Try to set via Team B — 403
	w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{
		"tool_names": []string{"web_search"},
	})
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Via correct team — 200
	w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil)
	if w.Code != http.StatusOK {
		t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
	}
}

// ── Team Persona Avatar ─────────────────────

func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) {
	h := setupHarness(t)
	adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com")

	// Create two teams
	w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Avatar Team A",
	})
	var teamA map[string]interface{}
	decode(w, &teamA)
	teamAID := teamA["id"].(string)

	w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
		"name": "Avatar Team B",
	})
	var teamB map[string]interface{}
	decode(w, &teamB)
	teamBID := teamB["id"].(string)

	// Create persona on Team A
	personaID := seedInsertReturningID(t, `
		INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
		VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true)
		RETURNING id
	`, teamAID, adminID)

	// Try to delete avatar via Team B — 403
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil)
	if w.Code != http.StatusForbidden {
		t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String())
	}

	// Try to delete via correct team — should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too)
	w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil)
	if w.Code != http.StatusOK && w.Code != http.StatusNotFound {
		t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String())
	}
}
