package auth_test

import (
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/auth"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	. "github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/core/secrets"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/mailer"
	"github.com/tidbcloud/AgentTeam/internal/passwords"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/organization"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

func syncFeishuDirectoryForAuthTests(t *testing.T, client *http.Client, baseURL string, accessToken string, organizationID string) string {
	t.Helper()

	var syncResult organization.OrganizationDirectorySyncRunSummaryView
	response := DoJSON(t, client, http.MethodPost, baseURL+"/api/v1/orgs/"+organizationID+"/feishu/sync", nil, AuthHeaders(accessToken), &syncResult)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, syncResult.SyncRunID)

	var detail organization.OrganizationDirectorySyncRunDetailView
	require.Eventually(t, func() bool {
		var latest organization.OrganizationDirectorySyncRunDetailView
		response := DoJSON(t, client, http.MethodGet, baseURL+"/api/v1/orgs/"+organizationID+"/directory-sync-runs/"+syncResult.SyncRunID, nil, AuthHeaders(accessToken), &latest)
		if response.StatusCode != http.StatusOK {
			return false
		}
		detail = latest
		return latest.Status != "running"
	}, 10*time.Second, 50*time.Millisecond)
	require.Equal(t, "completed", detail.Status)
	return syncResult.SyncRunID
}

func TestAuthRegisterRefreshLogoutFlow(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var registerResponse dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &registerResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, registerResponse.AccessToken)
	require.NotEmpty(t, registerResponse.RefreshToken)
	require.Equal(t, "admin@example.com", registerResponse.User.Email)

	var me dto.UserResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/me", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", registerResponse.AccessToken),
	}, &me)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "admin@example.com", me.Email)

	var refreshed struct {
		AccessToken  string `json:"accessToken"`
		RefreshToken string `json:"refreshToken"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/refresh", map[string]any{
		"refreshToken": registerResponse.RefreshToken,
	}, nil, &refreshed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, refreshed.AccessToken)
	require.NotEmpty(t, refreshed.RefreshToken)
	require.NotEqual(t, registerResponse.AccessToken, refreshed.AccessToken)
	require.NotEqual(t, registerResponse.RefreshToken, refreshed.RefreshToken)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/logout", map[string]any{
		"refreshToken": refreshed.RefreshToken,
	}, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", refreshed.AccessToken),
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/refresh", map[string]any{
		"refreshToken": refreshed.RefreshToken,
	}, nil, nil)
	require.Equal(t, http.StatusUnauthorized, response.StatusCode)
}

func TestAuthRegisterInviteOnlyRejectsUninvitedAfterBootstrap(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, nil))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "invite-only-admin@example.com",
		"password": TestUserPassword,
		"name":     "invite-only-admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "uninvited@example.com",
		"password": TestUserPassword,
		"name":     "uninvited",
	}, nil, &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Contains(t, apiErr.Error, "invite-only")
}

func TestAuthRegisterInviteOnlyAllowsPendingWorkspaceInvite(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, nil))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "invite-owner@example.com",
		"password": TestUserPassword,
		"name":     "invite-owner",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", owner.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)

	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaces[0].ID,
		"invited@example.com",
		secrets.HashToken("invite-token"),
		time.Now().UTC().Add(time.Hour),
	)
	require.NoError(t, err)

	var invited dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "invited@example.com",
		"password": TestUserPassword,
		"name":     "invited",
	}, nil, &invited)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "invited@example.com", invited.User.Email)
}

func TestAcceptInviteRestoresAllChannelMembershipForExistingActiveMember(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, nil))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "repair-owner@example.com",
		"password": TestUserPassword,
		"name":     "repair-owner",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)

	firstInviteToken := "tok_repair_member_first"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"repair-member@example.com",
		secrets.HashToken(firstInviteToken),
		time.Now().UTC().Add(time.Hour),
	)
	require.NoError(t, err)

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "repair-member@example.com",
		"password": TestUserPassword,
		"name":     "repair-member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": firstInviteToken,
	}, AuthHeaders(member.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var memberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human'
`, workspaceID, member.User.ID).Scan(&memberID))

	_, err = runtime.Pool.Exec(runtime.Ctx, `
DELETE FROM scope_notification_settings
WHERE scope_id = $1 AND member_id = $2
`, allChannelID, memberID)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
DELETE FROM scope_memberships
WHERE scope_id = $1 AND member_id = $2
`, allChannelID, memberID)
	require.NoError(t, err)

	secondInviteToken := "tok_repair_member_second"
	_, err = runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"repair-member@example.com",
		secrets.HashToken(secondInviteToken),
		time.Now().UTC().Add(time.Hour),
	)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": secondInviteToken,
	}, AuthHeaders(member.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var memberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/members", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &memberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	found := false
	for _, human := range memberList.Humans {
		if human.ID == member.User.ID {
			found = true
			require.NotEmpty(t, human.Name)
			break
		}
	}
	require.True(t, found)
}

func TestAuthRegisterInviteOnlyAllowsAllowlistedEmail(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, []string{"allowed-admin@example.com", "allowed@example.com"}))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "allowed-admin@example.com",
		"password": TestUserPassword,
		"name":     "allowlist-admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var allowed dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "allowed@example.com",
		"password": TestUserPassword,
		"name":     "allowed",
	}, nil, &allowed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "allowed@example.com", allowed.User.Email)
}

func TestAuthRegisterOpenModeRestrictsConfiguredAllowlist(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var legacy dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "legacy@qq.com",
		"password": TestUserPassword,
		"name":     "legacy",
	}, nil, &legacy)
	require.Equal(t, http.StatusOK, response.StatusCode)

	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeOpen, []string{"@ccb.com", "@ccbft.com", "@pingcap.com"}))

	var config dto.AppPublicConfigResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/public/config", nil, nil, &config)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, config.OpenRegistration)
	require.Equal(t, auth.RegistrationModeOpen, config.RegistrationMode)

	allowedEmails := []string{"alice@ccb.com", "bob@ccbft.com", "carol@pingcap.com"}
	for _, email := range allowedEmails {
		email := email
		name := strings.ReplaceAll(strings.Split(email, "@")[0], ".", "-")
		var allowed dto.AuthTokensResponse
		response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
			"email":    email,
			"password": TestUserPassword,
			"name":     name,
		}, nil, &allowed)
		require.Equal(t, http.StatusOK, response.StatusCode, email)
		require.Equal(t, email, allowed.User.Email)
	}

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "mallory@qq.com",
		"password": TestUserPassword,
		"name":     "mallory",
	}, nil, &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Contains(t, apiErr.Error, "allowed email domains")

	var login dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "legacy@qq.com",
		"password": TestUserPassword,
	}, nil, &login)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, legacy.User.ID, login.User.ID)
}

func TestAuthRegisterInviteOnlyAllowsDatabaseAllowlistedEmail(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, nil))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "allowlist-db-admin@example.com",
		"password": TestUserPassword,
		"name":     "allowlist-db-admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created identity.RegistrationAllowlistEntry
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/admin/registration-allowlist", map[string]any{
		"pattern": "db-allowed@example.com",
		"reason":  "temporary admin allowlist",
	}, AuthHeaders(admin.AccessToken), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "db-allowed@example.com", created.Pattern)

	var allowed dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "db-allowed@example.com",
		"password": TestUserPassword,
		"name":     "db-allowed",
	}, nil, &allowed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "db-allowed@example.com", allowed.User.Email)
}

func TestAuthRegisterInviteOnlyAllowsDomainAllowlistedEmail(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	runtime.SetRegistrationPolicy(auth.NewRegistrationPolicy(auth.RegistrationModeInviteOnly, nil))

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "allowlist-domain-admin@example.com",
		"password": TestUserPassword,
		"name":     "allowlist-domain-admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created identity.RegistrationAllowlistEntry
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/admin/registration-allowlist", map[string]any{
		"pattern": "@example.com",
		"reason":  "company domain allowlist",
	}, AuthHeaders(admin.AccessToken), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "@example.com", created.Pattern)

	var allowed dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "alice@example.com",
		"password": TestUserPassword,
		"name":     "alice",
	}, nil, &allowed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "alice@example.com", allowed.User.Email)
}

func TestAuthFeishuLoginCreatesSessionFromImportedDirectoryIdentity(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: TestFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := RegisterUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	orgSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	enterprise := PutOrganizationFeishuCredentialsForTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID, feishu.AppID(), feishu.AppSecret())
	require.True(t, enterprise.CredentialsConfigured)
	require.Equal(t, "organization", enterprise.CredentialSource)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, settings.Feishu.HasServerConfig)
	require.Empty(t, settings.Feishu.TenantKey)
	require.Empty(t, settings.Feishu.RootDepartmentID)

	syncFeishuDirectoryForAuthTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID)

	redirectURI := server.URL + "/login"
	var loginURLResponse struct {
		AuthorizeURL string `json:"authorizeUrl"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/feishu/login-url", map[string]any{
		"organizationSlug": orgSummary.Slug,
		"redirectUri":      redirectURI,
	}, nil, &loginURLResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, loginURLResponse.AuthorizeURL)

	parsedAuthorizeURL, err := url.Parse(loginURLResponse.AuthorizeURL)
	require.NoError(t, err)
	state := strings.TrimSpace(parsedAuthorizeURL.Query().Get("state"))
	require.NotEmpty(t, state)

	var auth dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/feishu/login", map[string]any{
		"code":        feishu.AuthCode(),
		"state":       state,
		"redirectUri": redirectURI,
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, auth.AccessToken)
	require.NotEmpty(t, auth.RefreshToken)
	require.True(t, auth.User.EmailVerified)
	require.True(t, strings.HasSuffix(auth.User.Email, "@directory.invalid"))

	var organizations []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, AuthHeaders(auth.AccessToken), &organizations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, OrganizationIDsFromSummaries(organizations), orgSummary.ID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", nil, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "tenant-key-1", settings.Feishu.TenantKey)
	require.Empty(t, settings.Feishu.RootDepartmentID)
	require.NotNil(t, settings.Feishu.LastSync)
	require.Equal(t, "completed", settings.Feishu.LastSync.Status)
	require.NotNil(t, settings.Feishu.LastSync.StartedAt)
	require.NotNil(t, settings.Feishu.LastSync.CompletedAt)
	require.Nil(t, settings.Feishu.LastAutoJoin)

	var bindingsCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM organization_auth_bindings
WHERE organization_id = $1
  AND organization_member_id = (
    SELECT id
    FROM organization_members
    WHERE organization_id = $1
      AND external_provider = 'feishu'
      AND external_id = 'ou-carol'
  )
`, orgSummary.ID).Scan(&bindingsCount))
	require.Equal(t, 2, bindingsCount)
}

func TestAuthLoginAutoJoinsFeishuOrganizationByVerifiedEmail(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: TestFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := RegisterUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	orgSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	enterprise := PutOrganizationFeishuCredentialsForTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID, feishu.AppID(), feishu.AppSecret())
	require.True(t, enterprise.CredentialsConfigured)
	require.Equal(t, "organization", enterprise.CredentialSource)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)

	syncFeishuDirectoryForAuthTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID)

	carolAuth := RegisterUserForOrganizationTests(t, client, server.URL, "carol@example.com", "carol")

	var orgsBefore []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, AuthHeaders(carolAuth.AccessToken), &orgsBefore)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, OrganizationIDsFromSummaries(orgsBefore), orgSummary.ID)

	var login dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "carol@example.com",
		"password": TestUserPassword,
	}, nil, &login)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, login.User.EmailVerified)

	var orgsAfter []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, AuthHeaders(login.AccessToken), &orgsAfter)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, OrganizationIDsFromSummaries(orgsAfter), orgSummary.ID)

	var reboundUserID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT user_id
FROM organization_members
WHERE organization_id = $1
  AND external_provider = 'feishu'
  AND external_id = 'ou-carol'
`, orgSummary.ID).Scan(&reboundUserID))
	require.Equal(t, login.User.ID, reboundUserID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", nil, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, settings.Feishu.LastSync)
	require.Equal(t, "completed", settings.Feishu.LastSync.Status)
	require.NotNil(t, settings.Feishu.LastAutoJoin)
	require.Equal(t, "success", settings.Feishu.LastAutoJoin.Status)
	require.Equal(t, "email", settings.Feishu.LastAutoJoin.SubjectType)
	require.Equal(t, "ca***@example.com", settings.Feishu.LastAutoJoin.SubjectHint)
	require.NotNil(t, settings.Feishu.LastAutoJoin.AttemptedAt)
	require.Contains(t, settings.Feishu.LastAutoJoin.Message, "joined")
}

func TestVerifiedMobileLoginAllowsWorkspaceAccessWhenEmailUnverified(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithEmailVerificationRequired(t, true)
	mobileSender := &TestMobileVerificationSender{}
	runtime.MobileVerificationSender = mobileSender

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, admin.User.EmailVerified)

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, member.User.EmailVerified)
	require.False(t, member.User.MobileVerified)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(member.AccessToken), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var mobileRequest struct {
		AlreadyVerified bool   `json:"alreadyVerified"`
		MobileHint      string `json:"mobileHint"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/mobile/request-verification", map[string]any{
		"mobile": "+1 (555) 123-4567",
	}, AuthHeaders(member.AccessToken), &mobileRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, mobileRequest.AlreadyVerified)
	require.Equal(t, "+1555*****67", mobileRequest.MobileHint)
	require.Equal(t, "+15551234567", mobileSender.LastMobile)
	require.Len(t, mobileSender.LastCode, MobileVerificationCodeLength)

	var verified dto.UserResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/mobile/verify", map[string]any{
		"code": mobileSender.LastCode,
	}, AuthHeaders(member.AccessToken), &verified)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "+15551234567", verified.Mobile)
	require.True(t, verified.MobileVerified)
	require.False(t, verified.EmailVerified)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(member.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, workspaces, 1)

	var login dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "+1 555 123 4567",
		"password": TestUserPassword,
	}, nil, &login)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, member.User.ID, login.User.ID)
	require.Equal(t, "+15551234567", login.User.Mobile)
	require.True(t, login.User.MobileVerified)
	require.False(t, login.User.EmailVerified)
}

func TestAuthLoginExposesFeishuAutoJoinFailureInOrgSettings(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: TestFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := RegisterUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	orgSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	enterprise := PutOrganizationFeishuCredentialsForTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID, feishu.AppID(), feishu.AppSecret())
	require.True(t, enterprise.CredentialsConfigured)
	require.Equal(t, "organization", enterprise.CredentialSource)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Nil(t, settings.Feishu.LastSync)
	require.Nil(t, settings.Feishu.LastAutoJoin)

	carolAuth := RegisterUserForOrganizationTests(t, client, server.URL, "carol@example.com", "carol")

	var login dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "carol@example.com",
		"password": TestUserPassword,
	}, nil, &login)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, carolAuth.User.ID, login.User.ID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", nil, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Nil(t, settings.Feishu.LastSync)
	require.NotNil(t, settings.Feishu.LastAutoJoin)
	require.Equal(t, "directory_user_not_imported", settings.Feishu.LastAutoJoin.Status)
	require.Equal(t, "email", settings.Feishu.LastAutoJoin.SubjectType)
	require.Equal(t, "ca***@example.com", settings.Feishu.LastAutoJoin.SubjectHint)
	require.NotNil(t, settings.Feishu.LastAutoJoin.AttemptedAt)
	require.Contains(t, settings.Feishu.LastAutoJoin.Message, "Run Feishu sync first")
}

func TestAuthLoginAutoJoinsFeishuOrganizationByVerifiedMobile(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, true, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: TestFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := RegisterUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	orgSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	enterprise := PutOrganizationFeishuCredentialsForTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID, feishu.AppID(), feishu.AppSecret())
	require.True(t, enterprise.CredentialsConfigured)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)

	syncFeishuDirectoryForAuthTests(t, client, server.URL, ownerAuth.AccessToken, orgSummary.ID)

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)

	mobileCode := "654321"
	require.NoError(t, runtime.Store.Identity().CreateMobileVerificationToken(
		runtime.Ctx,
		member.User.ID,
		"+8613800000000",
		secrets.HashToken(mobileCode),
		time.Now().UTC().Add(10*time.Minute),
	))
	verifiedUser, err := runtime.Store.Identity().VerifyMobileToken(runtime.Ctx, member.User.ID, mobileCode)
	require.NoError(t, err)
	require.True(t, verifiedUser.HasVerifiedMobile())
	require.False(t, verifiedUser.HasVerifiedEmail())

	var login dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "+86 138 0000 0000",
		"password": TestUserPassword,
	}, nil, &login)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, member.User.ID, login.User.ID)
	require.True(t, login.User.MobileVerified)
	require.False(t, login.User.EmailVerified)

	var orgsAfter []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, AuthHeaders(login.AccessToken), &orgsAfter)
	require.Equal(t, http.StatusOK, response.StatusCode)
	t.Logf("orgsAfter=%v", OrganizationIDsFromSummaries(orgsAfter))

	var reboundUserID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT user_id
FROM organization_members
WHERE organization_id = $1
  AND external_provider = 'feishu'
  AND external_id = 'ou-carol'
`, orgSummary.ID).Scan(&reboundUserID))
	require.Equal(t, login.User.ID, reboundUserID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+orgSummary.ID+"/settings", nil, AuthHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, settings.Feishu.LastAutoJoin)
	require.Equal(t, "success", settings.Feishu.LastAutoJoin.Status)
	require.Equal(t, "mobile", settings.Feishu.LastAutoJoin.SubjectType)
	require.Equal(t, "+8613*******00", settings.Feishu.LastAutoJoin.SubjectHint)
	require.Contains(t, settings.Feishu.LastAutoJoin.Message, "joined")
}

func TestAuthUpdateMePasswordChangeUpdatesCredentials(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	oldPassword := TestUserPassword
	newPassword := "next-password-is-long-enough"

	var registerResponse dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": oldPassword,
		"name":     "admin",
	}, nil, &registerResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, registerResponse.AccessToken)

	var updated dto.UserResponse
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/me", map[string]any{
		"currentPassword": oldPassword,
		"newPassword":     newPassword,
	}, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", registerResponse.AccessToken),
	}, &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "admin@example.com", updated.Email)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "admin@example.com",
		"password": oldPassword,
	}, nil, &apiErr)
	require.Equal(t, http.StatusUnauthorized, response.StatusCode)
	require.Equal(t, "Invalid credentials", apiErr.Error)

	var loginResponse dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/login", map[string]any{
		"email":    "admin@example.com",
		"password": newPassword,
	}, nil, &loginResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, loginResponse.AccessToken)
	require.Equal(t, "admin@example.com", loginResponse.User.Email)
}

func TestAuthPasswordMinLengthIsSix(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var apiErr struct {
		Error string `json:"error"`
	}
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": "12345",
		"name":     "admin",
	}, nil, &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "Password must be at least 6 characters", apiErr.Error)

	var auth dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": "123456",
		"name":     "admin",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, auth.AccessToken)
}

func TestAuthRegisterAllowsSameEmailLocalPartAcrossDomains(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, admin.AccessToken)

	var first dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "alice@example.com",
		"password": TestUserPassword,
		"name":     "alice",
	}, nil, &first)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var second dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "alice@another.com",
		"password": TestUserPassword,
		"name":     "alice2",
	}, nil, &second)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var firstWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", first.AccessToken),
	}, &firstWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, firstWorkspaces, 1)
	require.NotEmpty(t, firstWorkspaces[0].Slug)

	var secondWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", second.AccessToken),
	}, &secondWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, secondWorkspaces, 1)
	require.NotEmpty(t, secondWorkspaces[0].Slug)

	require.NotEqual(t, firstWorkspaces[0].Slug, secondWorkspaces[0].Slug)
}

func TestAuthRegisterConflictErrorsAreReadable(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, admin.AccessToken)

	var apiErr struct {
		Error string `json:"error"`
	}

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "someone-else",
	}, nil, &apiErr)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, "Email already registered. Try signing in instead.", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "other@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &apiErr)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, "Name already taken. Try another one.", apiErr.Error)
}

func TestEmailVerificationRequiredBlocksWorkspaceAccessUntilVerified(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithEmailVerificationRequired(t, true)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, admin.User.EmailVerified, "bootstrap admin should be verified")

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, member.User.EmailVerified)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", member.AccessToken),
	}, nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	verificationToken := "tok_test_verification"
	require.NoError(t, runtime.Store.Identity().CreateVerificationToken(
		runtime.Ctx,
		member.User.ID,
		secrets.HashToken(verificationToken),
		time.Now().UTC().Add(10*time.Minute),
	))

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/verify-email", map[string]any{
		"token": verificationToken,
	}, nil, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", member.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
}

func TestEmailVerificationNotRequiredDoesNotBlockUnverifiedUser(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithEmailVerificationRequired(t, false)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, admin.User.EmailVerified, "bootstrap admin should be verified")

	passwordHash, err := passwords.Hash(TestUserPassword)
	require.NoError(t, err)

	unverifiedUser, err := runtime.Store.Identity().RegisterUser(
		runtime.Ctx,
		"member@example.com",
		"member",
		"member",
		passwordHash,
		secrets.HashToken("tok_test_verification"),
		time.Now().UTC().Add(10*time.Minute),
	)
	require.NoError(t, err)
	require.Nil(t, unverifiedUser.EmailVerifiedAt)
	require.NoError(t, runtime.Organizations.EnsureDefaultWorkspaceForUser(runtime.Ctx, unverifiedUser))

	accessToken := "tok_test_access"
	_, err = runtime.Store.Identity().CreateSession(runtime.Ctx, unverifiedUser.ID, secrets.HashToken(accessToken), time.Now().UTC().Add(10*time.Minute))
	require.NoError(t, err)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", accessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
}

func TestAuthRegisterFailsWhenVerificationEmailSendFails(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithMailer(t, true, 30, func(_ *slog.Logger) mailer.Mailer {
		return &TestControlledMailer{
			VerificationFailOnCall: 1,
			VerificationErr:        errors.New("mailer boom"),
		}
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, admin.User.EmailVerified, "bootstrap admin should be verified")

	var apiErr struct {
		Error string `json:"error"`
		Code  string `json:"code"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &apiErr)
	require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
	require.Equal(t, ErrorCodeVerificationEmailSendFailed, apiErr.Code)
	require.NotEmpty(t, apiErr.Error)

	_, err := runtime.Store.Identity().FindUserByEmail(runtime.Ctx, "member@example.com")
	require.ErrorIs(t, err, storeerr.ErrNotFound, "failed register should not reserve the email")

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "member@example.com", member.User.Email)
	require.False(t, member.User.EmailVerified)
}

func TestAuthResendVerificationFailsWhenVerificationEmailSendFails(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithMailer(t, true, 30, func(_ *slog.Logger) mailer.Mailer {
		return &TestControlledMailer{
			VerificationFailOnCall: 2,
			VerificationErr:        errors.New("mailer boom"),
		}
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var admin dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "admin@example.com",
		"password": TestUserPassword,
		"name":     "admin",
	}, nil, &admin)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, member.User.EmailVerified)

	var apiErr struct {
		Error string `json:"error"`
		Code  string `json:"code"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/resend-verification", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", member.AccessToken),
	}, &apiErr)
	require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
	require.Equal(t, ErrorCodeVerificationEmailSendFailed, apiErr.Code)
	require.NotEmpty(t, apiErr.Error)
}

func TestAuthInviteInfoReturnsBadRequestWhenInviteIsRevoked(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "owner@example.com",
		"password": TestUserPassword,
		"name":     "owner",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", owner.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)

	workspaceID := workspaces[0].ID

	token := "tok_test_revoked_invite"
	inviteID, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"revoked@example.com",
		secrets.HashToken(token),
		time.Now().UTC().Add(10*time.Minute),
	)
	require.NoError(t, err)

	require.NoError(t, runtime.Store.App().RevokeWorkspaceInviteForUser(runtime.Ctx, owner.User.ID, workspaceID, inviteID))

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/auth/invite-info?token="+token, nil, nil, &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "invite revoked", apiErr.Error)
}
