package handler

import (
	"baron-sso-backend/internal/domain"
	"baron-sso-backend/internal/service"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"
)

// MockTenantService is a mock for service.TenantService
type MockTenantService struct {
	mock.Mock
}

func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
	args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

func (m *MockTenantService) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
	args := m.Called(ctx, domainName)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

func (m *MockTenantService) ApproveTenant(ctx context.Context, tenantID string) error {
	args := m.Called(ctx, tenantID)
	return args.Error(0)
}

func (m *MockTenantService) RequestRegistration(ctx context.Context, name, slug, description, domainName, adminEmail string) (*domain.Tenant, error) {
	args := m.Called(ctx, name, slug, description, domainName, adminEmail)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

func (m *MockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
	args := m.Called(ctx, slug)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
	args := m.Called(ctx, id)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
	args := m.Called(ctx, limit, offset, parentID)
	return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
}

func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
	args := m.Called(ctx, userID)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]domain.Tenant), args.Error(1)
}

func (m *MockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
	args := m.Called(ctx, domainName)
	return args.Bool(0), args.Error(1)
}

func (m *MockTenantService) SetKetoService(keto service.KetoService) {
	m.Called(keto)
}

func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
	args := m.Called(ctx, domainName)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*domain.Tenant), args.Error(1)
}

type MockUserRepoForHandler struct {
	mock.Mock
	deletedIDs []string
}

func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
	m.deletedIDs = append(m.deletedIDs, id)
	return nil
}

func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
	for _, call := range m.ExpectedCalls {
		if call.Method == "List" {
			args := m.Called(ctx, offset, limit, search, tenantSlug)
			return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
		}
	}
	return nil, 0, nil
}

func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
	return 0, nil
}

func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
	args := m.Called(ctx, tenantIDs)
	return args.Get(0).([]domain.User), args.Error(1)
}

func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
	args := m.Called(ctx, codes)
	return args.Get(0).([]domain.User), args.Error(1)
}

func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
	args := m.Called(ctx, codes)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(map[string]int64), args.Error(1)
}

func (m *MockUserRepoForHandler) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
	return nil
}

func (m *MockUserRepoForHandler) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
	return nil, nil
}

func (m *MockUserRepoForHandler) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
	return false, nil
}

func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
	return "", nil
}

type MockUserProjectionRepoForHandler struct {
	mock.Mock
}

func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
	args := m.Called(ctx)
	return args.Bool(0), args.Error(1)
}

func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
	args := m.Called(ctx)
	return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
}

func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
	args := m.Called(ctx, tenants)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(map[string]int64), args.Error(1)
}

func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
	args := m.Called(ctx, users)
	return args.Error(0)
}

func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
	args := m.Called(ctx, syncErr)
	return args.Error(0)
}

func toJSONString(t *testing.T, value any) string {
	t.Helper()
	raw, err := json.Marshal(value)
	require.NoError(t, err)
	return string(raw)
}

func TestTenantHandler_CreateTenant(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	// CreateTenant checks h.DB != nil
	h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}

	app.Post("/tenants", h.CreateTenant)

	input := map[string]interface{}{
		"name":    "Test Tenant",
		"slug":    "test-tenant",
		"domains": []string{"test.com"},
	}
	body, _ := json.Marshal(input)

	mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string(nil), (*string)(nil), "").
		Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)

	req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusCreated, resp.StatusCode)
	var got map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&got)
	assert.Equal(t, "t1", got["id"])
}

func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	tenants := []domain.Tenant{
		{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
	}
	mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, tenants).
		Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()

	req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
	resp, _ := app.Test(req)

	require.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	json.NewDecoder(resp.Body).Decode(&res)

	require.Len(t, res.Items, 1)
	assert.Equal(t, int64(2), res.Items[0].MemberCount)
	mockProjection.AssertExpectations(t)
}

func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	tenants := []domain.Tenant{
		{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
	}
	mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()

	req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
	mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
}

func TestTenantHandler_ListTenants(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)
	tenants := []domain.Tenant{
		{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
		{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
	}

	// Mocking for the new allTenants check in ListTenants
	mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, tenants).
		Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()

	req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	json.NewDecoder(resp.Body).Decode(&res)

	assert.Equal(t, int64(2), res.Total)
	assert.Len(t, res.Items, 2)

	// Check if counts are mapped correctly
	for _, item := range res.Items {
		if item.Slug == "slug-a" {
			assert.Equal(t, int64(5), item.MemberCount)
		} else if item.Slug == "slug-b" {
			assert.Equal(t, int64(10), item.MemberCount)
		}
	}
}

func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
	tenants := []domain.Tenant{
		{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
		{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)},
	}

	mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()

	req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
	resp, _ := app.Test(req)

	require.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
	require.Len(t, res.Items, 2)
	require.NotEmpty(t, res.NextCursor)
}

func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
	createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
	tenants := []domain.Tenant{
		{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt},
		{ID: "00000000-0000-0000-0000-000000000003", Name: "Tenant C", Slug: "slug-c", CreatedAt: createdAt},
		{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
	}

	page, nextCursor, err := pageTenantsByCursor(tenants, 2, "")

	require.NoError(t, err)
	require.NotEmpty(t, nextCursor)
	require.Equal(t, []string{
		"00000000-0000-0000-0000-000000000003",
		"00000000-0000-0000-0000-000000000002",
	}, []string{page[0].ID, page[1].ID})

	nextPage, _, err := pageTenantsByCursor(tenants, 2, nextCursor)

	require.NoError(t, err)
	require.Equal(t, []string{"00000000-0000-0000-0000-000000000001"}, []string{nextPage[0].ID})
}

func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
		{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
		{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
		{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
		{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			ID:       "user-1",
			Role:     domain.RoleTenantAdmin,
			TenantID: parent("company"),
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
		return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
	})).Return(map[string]int64{}, nil).Once()

	req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
	require.Equal(t, int64(3), res.Total)
	require.NotContains(t, toJSONString(t, res), "private-team")
	require.NotContains(t, toJSONString(t, res), "private-child")
}

func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
		{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
		{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
		{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			ID:       "user-1",
			Role:     domain.RoleTenantAdmin,
			TenantID: parent("company"),
			ManageableTenants: []domain.Tenant{
				{ID: "private-team", Slug: "private-team"},
			},
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
		return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
	})).Return(map[string]int64{}, nil).Once()

	req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
	require.Equal(t, int64(4), res.Total)
	require.Contains(t, toJSONString(t, res), "private-team")
	require.Contains(t, toJSONString(t, res), "private-child")
}

func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
		{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
		{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
		{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
	}
	mockKeto := new(devMockKetoService)
	h := &TenantHandler{Keto: mockKeto}

	mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", "view_private").Return(true, nil).Once()

	filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
		ID:       "user-1",
		Role:     domain.RoleTenantAdmin,
		TenantID: parent("company"),
	})

	require.NoError(t, err)
	require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child"))
	mockKeto.AssertExpectations(t)
}

func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
	if len(got) != len(want) {
		return false
	}
	counts := make(map[string]int, len(want))
	for _, slug := range want {
		counts[slug]++
	}
	for _, tenant := range got {
		counts[tenant.Slug]--
		if counts[tenant.Slug] < 0 {
			return false
		}
	}
	return true
}

func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockUsers := new(MockUserRepoForHandler)
	h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("apiKeyName", "orgfront-ssot-client")
		return c.Next()
	})
	app.Get("/org-context", h.GetOrgContext)

	now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "root-other", Type: domain.TenantTypeCompanyGroup, Name: "다른그룹", Slug: "other-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, Config: domain.JSONMap{"orgUnitType": "실"}, CreatedAt: now, UpdatedAt: now},
		{ID: "team-sso", Type: domain.TenantTypeUserGroup, ParentID: parent("dept-platform"), Name: "SSO팀", Slug: "sso", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now},
	}
	usersByTenantID := []domain.User{
		{
			ID:          "user-platform-lead",
			Email:       "lead@example.com",
			Name:        "플랫폼 리드",
			Phone:       "010-1111-2222",
			Status:      domain.UserStatusActive,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			Grade:       "책임",
			Position:    "실장",
			JobTitle:    "Backend Engineer",
			Metadata: domain.JSONMap{
				"additionalAppointments": []any{
					map[string]any{
						"tenantId":  "dept-platform",
						"isPrimary": true,
						"isOwner":   true,
						"grade":     "수석",
						"position":  "실장",
						"jobTitle":  "기술기획",
					},
				},
			},
			CreatedAt: now,
			UpdatedAt: now,
		},
		{
			ID:          "user-archived",
			Email:       "archived@example.com",
			Name:        "보관 사용자",
			Status:      domain.UserStatusArchived,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
		{
			ID:          "user-suspended",
			Email:       "suspended@example.com",
			Name:        "정지 사용자",
			Status:      domain.UserStatusSuspended,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
		{
			ID:          "user-temporary-leave",
			Email:       "temporary-leave@example.com",
			Name:        "단기휴무 사용자",
			Status:      domain.UserStatusTemporaryLeave,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
		{
			ID:          "user-preboarding",
			Email:       "preboarding@example.com",
			Name:        "입사대기 사용자",
			Status:      domain.UserStatusPreboarding,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
		{
			ID:          "user-baron-guest",
			Email:       "baron-guest@example.com",
			Name:        "Baron Guest",
			Status:      domain.UserStatusBaronGuest,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
		{
			ID:          "user-extended-leave",
			Email:       "extended-leave@example.com",
			Name:        "장기휴직 사용자",
			Status:      domain.UserStatusExtendedLeave,
			TenantID:    parent("dept-platform"),
			CompanyCode: "platform",
			CreatedAt:   now,
			UpdatedAt:   now,
		},
	}
	usersBySlug := []domain.User{
		{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
	}
	usersByList := []domain.User{
		{
			ID:     "user-appointment-only",
			Email:  "appointment@example.com",
			Name:   "겸직 사용자",
			Status: domain.UserStatusActive,
			Metadata: domain.JSONMap{
				"additionalAppointments": []any{
					map[string]any{
						"tenantSlug": "sso",
						"lead":       true,
						"position":   "파트장",
					},
				},
			},
			CreatedAt: now,
			UpdatedAt: now,
		},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
	mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
	mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil)
	mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil)

	req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	var got map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
	require.Equal(t, "baron.org-context.v1", got["schemaVersion"])

	scope := got["scope"].(map[string]any)
	require.Equal(t, "group-hanmac-family", scope["tenantId"])
	require.Equal(t, "hanmac-family", scope["tenantSlug"])

	tenantsPayload := got["tenants"].([]any)
	require.Len(t, tenantsPayload, 4)
	require.Equal(t, "group-hanmac-family", tenantsPayload[0].(map[string]any)["id"])
	require.Equal(t, "company-hanmac", tenantsPayload[1].(map[string]any)["id"])
	require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"])
	require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"])

	require.NotContains(t, got, "users")
	deptPlatform := tenantsPayload[2].(map[string]any)
	platformMembers := deptPlatform["members"].([]any)
	require.Len(t, platformMembers, 3)
	firstUser := platformMembers[0].(map[string]any)
	require.NotContains(t, firstUser, "id")
	require.NotContains(t, firstUser, "phone")
	require.NotContains(t, firstUser, "tenantIds")
	require.NotContains(t, firstUser, "tenantSlugs")
	require.NotContains(t, firstUser, "memberships")
	require.NotContains(t, firstUser, "role")
	require.NotContains(t, firstUser, "status")
	require.NotContains(t, firstUser, "metadata")
	require.NotContains(t, firstUser, "createdAt")
	require.NotContains(t, firstUser, "updatedAt")
	require.Equal(t, "lead@example.com", firstUser["email"])
	require.Equal(t, "플랫폼 리드", firstUser["name"])
	require.Equal(t, true, firstUser["isOwner"])
	require.Equal(t, true, firstUser["isLeader"])
	require.Equal(t, true, firstUser["isPrimary"])
	require.Equal(t, "수석", firstUser["grade"])
	require.Equal(t, "실장", firstUser["position"])
	require.Equal(t, "기술기획", firstUser["jobTitle"])
	teamSSO := tenantsPayload[3].(map[string]any)
	ssoMembers := teamSSO["members"].([]any)
	require.Len(t, ssoMembers, 1)
	appointmentOnly := ssoMembers[0].(map[string]any)
	require.Equal(t, "appointment@example.com", appointmentOnly["email"])
	require.Equal(t, false, appointmentOnly["isOwner"])
	require.Equal(t, true, appointmentOnly["isLeader"])

	tree := got["tree"].(map[string]any)
	require.Equal(t, "group-hanmac-family", tree["id"])
	require.NotContains(t, tree, "directUserIds")
	require.Contains(t, tree, "members")
	require.NotContains(t, toJSONString(t, got), "directUserIds")
	require.NotContains(t, toJSONString(t, got), "private-team")
	require.NotContains(t, toJSONString(t, got), "root-other")
	require.NotContains(t, toJSONString(t, got), "archived@example.com")
	require.Contains(t, toJSONString(t, got), "suspended@example.com")
	require.Contains(t, toJSONString(t, got), "temporary-leave@example.com")
	require.NotContains(t, toJSONString(t, got), "preboarding@example.com")
	require.NotContains(t, toJSONString(t, got), "baron-guest@example.com")
	require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
}

func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockUsers := new(MockUserRepoForHandler)
	h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("apiKeyName", "orgfront-ssot-client")
		return c.Next()
	})
	app.Get("/org-context", h.GetOrgContext)

	now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "company-hanmac", Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
	}
	users := []domain.User{
		{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
	mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
	mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)

	req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUserIds=true", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	var got map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
	require.NotContains(t, got, "users")
	tenantsPayload := got["tenants"].([]any)
	members := tenantsPayload[0].(map[string]any)["members"].([]any)
	require.Len(t, members, 1)
	member := members[0].(map[string]any)
	require.Equal(t, "user-1", member["id"])
	require.Equal(t, "010-1234-5678", member["phone"])
	require.NotContains(t, member, "tenantIds")
	require.NotContains(t, member, "tenantSlugs")
	require.NotContains(t, member, "memberships")
	tree := got["tree"].(map[string]any)
	treeMembers := tree["members"].([]any)
	require.Len(t, treeMembers, 1)
	require.Equal(t, "user-1", treeMembers[0].(map[string]any)["id"])
	require.Equal(t, "010-1234-5678", treeMembers[0].(map[string]any)["phone"])
	require.NotContains(t, tree, "directUserIds")
}

func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockUsers := new(MockUserRepoForHandler)
	h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("apiKeyName", "orgfront-ssot-client")
		return c.Next()
	})
	app.Get("/org-context", h.GetOrgContext)

	now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
		{ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
	}
	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
	mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
	mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)

	req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	var got map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
	scope := got["scope"].(map[string]any)
	require.Equal(t, "company-hanmac", scope["tenantId"])
	require.Equal(t, "hanmac", scope["tenantSlug"])

	require.Contains(t, toJSONString(t, got), "dept-platform")
	require.NotContains(t, toJSONString(t, got), "company-other")
}

func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
	app := fiber.New()
	h := &TenantHandler{}
	app.Get("/org-context", h.GetOrgContext)

	req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	tenants := []domain.Tenant{
		{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
	}
	mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()

	req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
	mockProjection.AssertExpectations(t)
}

func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	mockUserRepo := new(MockUserRepoForHandler)
	mockProjection := new(MockUserProjectionRepoForHandler)

	h := &TenantHandler{
		Service:            mockSvc,
		UserRepo:           mockUserRepo,
		UserProjectionRepo: mockProjection,
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			Role: "super_admin",
		})
		return c.Next()
	})
	app.Get("/tenants", h.ListTenants)

	tenants := []domain.Tenant{
		{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
	}

	mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
	mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
	mockProjection.On("CountTenantMembers", mock.Anything, tenants).
		Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()

	mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
		Return(map[string]int64{"saman": 152}, nil).Maybe()
	mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
		Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()

	req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var res tenantListResponse
	json.NewDecoder(resp.Body).Decode(&res)

	assert.Len(t, res.Items, 1)
	assert.Equal(t, int64(2), res.Items[0].MemberCount)
	mockProjection.AssertExpectations(t)
}

func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Get("/tenants/export", h.ExportTenantsCSV)

	parentID := "parent-1"
	tenants := []domain.Tenant{
		{
			ID:          "t1",
			Name:        "Tenant A",
			Type:        domain.TenantTypeCompany,
			ParentID:    &parentID,
			Slug:        "tenant-a",
			Description: "Primary tenant",
			Config: domain.JSONMap{
				"visibility":  "internal",
				"orgUnitType": "센터",
			},
			Domains: []domain.TenantDomain{
				{Domain: "tenant-a.example.com"},
				{Domain: "login.tenant-a.example.com"},
			},
		},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)

	req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
	resp, _ := app.Test(req)
	body, _ := io.ReadAll(resp.Body)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
	assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
	assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
	assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터")
}

func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Get("/tenants/export", h.ExportTenantsCSV)

	parentID := "parent-1"
	tenants := []domain.Tenant{
		{
			ID:   parentID,
			Name: "Parent Tenant",
			Type: domain.TenantTypeCompanyGroup,
			Slug: "parent-tenant",
		},
		{
			ID:       "child-1",
			Name:     "Child Tenant",
			Type:     domain.TenantTypeUserGroup,
			ParentID: &parentID,
			Slug:     "child-tenant",
		},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil)

	req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil)
	resp, _ := app.Test(req)
	body, _ := io.ReadAll(resp.Body)
	text := string(body)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
	assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
	assert.NotContains(t, text, "tenant_id")
	assert.NotContains(t, text, "parent_tenant_id")
	assert.NotContains(t, text, "child-1")
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Get("/tenants/export", h.ExportTenantsCSV)

	oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
	middle := oldest.Add(time.Hour)
	newest := oldest.Add(2 * time.Hour)
	tenants := []domain.Tenant{
		{ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest},
		{ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle},
		{ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)

	req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
	resp, _ := app.Test(req)
	body, _ := io.ReadAll(resp.Body)
	lines := strings.Split(strings.TrimSpace(string(body)), "\n")

	require.Equal(t, http.StatusOK, resp.StatusCode)
	require.Len(t, lines, 4)
	assert.Contains(t, lines[1], "oldest,Oldest Tenant")
	assert.Contains(t, lines[2], "middle,Middle Tenant")
	assert.Contains(t, lines[3], "newest,Newest Tenant")
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Get("/tenants/export", h.ExportTenantsCSV)

	parentID := "11111111-2222-4333-8444-555555555555"
	childID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
	grandchildID := "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
	unrelatedID := "cccccccc-dddd-4eee-8fff-111111111111"
	tenants := []domain.Tenant{
		{
			ID:   parentID,
			Name: "Parent Org",
			Type: domain.TenantTypeCompany,
			Slug: "parent-org",
		},
		{
			ID:       childID,
			Name:     "Child Org",
			Type:     domain.TenantTypeOrganization,
			ParentID: &parentID,
			Slug:     "child-org",
		},
		{
			ID:       grandchildID,
			Name:     "Leaf Team",
			Type:     domain.TenantTypeUserGroup,
			ParentID: &childID,
			Slug:     "leaf-team",
		},
		{
			ID:   unrelatedID,
			Name: "Unrelated Org",
			Type: domain.TenantTypeOrganization,
			Slug: "unrelated-org",
		},
	}

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)

	req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil)
	resp, _ := app.Test(req)
	body, _ := io.ReadAll(resp.Body)
	text := string(body)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
	assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,")
	assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,")
	assert.NotContains(t, text, unrelatedID)
	assert.NotContains(t, text, "Parent Org")
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	parent := func(id string) *string { return &id }
	tenants := []domain.Tenant{
		{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
		{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
		{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
		{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
		{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
	}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{
			ID:       "user-1",
			Role:     domain.RoleTenantAdmin,
			TenantID: parent("company"),
		})
		return c.Next()
	})
	app.Get("/tenants/export", h.ExportTenantsCSV)

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()

	req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
	resp, _ := app.Test(req)
	body, _ := io.ReadAll(resp.Body)
	text := string(body)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Contains(t, text, "public-team")
	assert.NotContains(t, text, "private-team")
	assert.NotContains(t, text, "private-child")
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Post("/tenants/import", h.ImportTenantsCSV)

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "tenants.csv")
	assert.NoError(t, err)
	_, err = part.Write([]byte("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\n"))
	assert.NoError(t, err)
	assert.NoError(t, writer.Close())

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
	mockSvc.On(
		"RegisterTenant",
		mock.Anything,
		"Imported Tenant",
		"imported-tenant",
		domain.TenantTypeCompany,
		"Imported memo",
		[]string{"imported.example.com", "login.imported.example.com"},
		mock.MatchedBy(func(parentID *string) bool {
			return parentID != nil && *parentID == "parent-1"
		}),
		"",
	).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil)

	req := httptest.NewRequest("POST", "/tenants/import", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	var got map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&got)
	assert.Equal(t, float64(1), got["created"])
	assert.Equal(t, float64(0), got["updated"])
	assert.Equal(t, float64(0), got["failed"])
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Post("/tenants/import", h.ImportTenantsCSV)

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "tenants.csv")
	assert.NoError(t, err)
	_, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,ORGANIZATION,parent-slug,child-slug,,\n"))
	assert.NoError(t, err)
	assert.NoError(t, writer.Close())

	parentID := "parent-id"
	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
	mockSvc.On(
		"RegisterTenant",
		mock.Anything,
		"Parent Tenant",
		"parent-slug",
		domain.TenantTypeCompany,
		"",
		[]string{},
		(*string)(nil),
		"",
	).Return(&domain.Tenant{ID: parentID, Name: "Parent Tenant", Slug: "parent-slug"}, nil).Once()
	mockSvc.On(
		"RegisterTenant",
		mock.Anything,
		"Child Tenant",
		"child-slug",
		domain.TenantTypeOrganization,
		"",
		[]string{},
		mock.MatchedBy(func(got *string) bool {
			return got != nil && *got == parentID
		}),
		"",
	).Return(&domain.Tenant{ID: "child-id", Name: "Child Tenant", Slug: "child-slug"}, nil).Once()

	req := httptest.NewRequest("POST", "/tenants/import", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	var got map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&got)
	assert.Equal(t, float64(2), got["created"])
	assert.Equal(t, float64(0), got["failed"])
	mockSvc.AssertExpectations(t)
}

func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id"})
		return c.Next()
	})
	app.Post("/tenants/import", h.ImportTenantsCSV)

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "tenants.csv")
	assert.NoError(t, err)
	_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Org,ORGANIZATION,parent-1,imported-org,,\n"))
	assert.NoError(t, err)
	assert.NoError(t, writer.Close())

	mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
	mockSvc.On(
		"RegisterTenant",
		mock.Anything,
		"Imported Org",
		"imported-org",
		domain.TenantTypeOrganization,
		"",
		[]string{},
		mock.MatchedBy(func(parentID *string) bool {
			return parentID != nil && *parentID == "parent-1"
		}),
		"",
	).Return(&domain.Tenant{ID: "imported-org-id", Name: "Imported Org", Slug: "imported-org"}, nil).Once()

	req := httptest.NewRequest("POST", "/tenants/import", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	var got map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&got)
	assert.Equal(t, float64(1), got["created"])
	assert.Equal(t, float64(0), got["failed"])
	mockSvc.AssertExpectations(t)
}

func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
	assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}

func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
	records, err := parseTenantCSVRecords(strings.NewReader(
		"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n" +
			"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\n",
	))

	assert.NoError(t, err)
	assert.Len(t, records, 1)
	assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
	assert.Equal(t, "internal", records[0].Visibility)
	assert.Equal(t, "센터", records[0].OrgUnitType)
}

func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
	got := normalizeTenantDomainInputs([]string{
		"samaneng.com, hanmaceng.co.kr",
		" LOGIN.HMAC.KR\nportal.hmac.kr ",
		"samaneng.com",
	})

	assert.Equal(t, []string{
		"samaneng.com",
		"hanmaceng.co.kr",
		"login.hmac.kr",
		"portal.hmac.kr",
	}, got)
}

func TestNormalizeTenantConfigForcesIndexedForLoginIDFields(t *testing.T) {
	config, err := normalizeTenantConfig(map[string]any{
		"userSchema": []any{
			map[string]any{
				"key":       "emp_no",
				"label":     "사번",
				"type":      "text",
				"indexed":   false,
				"isLoginId": true,
				"maxLength": 20,
			},
		},
	})

	assert.NoError(t, err)
	fields, ok := config["userSchema"].([]any)
	assert.True(t, ok)
	assert.Len(t, fields, 1)

	field, ok := fields[0].(map[string]any)
	assert.True(t, ok)
	assert.Equal(t, true, field["indexed"])
	assert.Equal(t, true, field["isLoginId"])
	assert.NotContains(t, field, "maxLength")
}

func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
	_, err := normalizeTenantConfig(map[string]any{
		"userSchema": []any{
			map[string]any{
				"key":       "emp_no",
				"type":      "number",
				"isLoginId": true,
			},
		},
	})

	assert.Error(t, err)
	assert.Contains(t, err.Error(), "login ID fields must be text")
}

func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
	config, err := normalizeTenantConfig(map[string]any{
		"visibility":  "internal",
		"orgUnitType": "센터",
	})

	assert.NoError(t, err)
	assert.Equal(t, "internal", config["visibility"])
	assert.Equal(t, "센터", config["orgUnitType"])
}

func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
	for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} {
		t.Run(orgUnitType, func(t *testing.T) {
			config, err := normalizeTenantConfig(map[string]any{
				"orgUnitType": orgUnitType,
			})

			assert.NoError(t, err)
			assert.Equal(t, orgUnitType, config["orgUnitType"])
		})
	}
}

func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) {
	_, err := normalizeTenantConfig(map[string]any{
		"visibility": "secret",
	})

	assert.Error(t, err)
	assert.Contains(t, err.Error(), "visibility must be public, internal, or private")
}

func TestValidateTenantOrgConfigScopeRequiresHanmacFamilyDescendant(t *testing.T) {
	hanmacFamily := domain.Tenant{ID: "family", Slug: "hanmac-family", Type: domain.TenantTypeCompanyGroup}
	saman := domain.Tenant{ID: "saman", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &hanmacFamily.ID}
	outsider := domain.Tenant{ID: "outsider", Slug: "outsider", Type: domain.TenantTypeCompany}

	err := validateTenantOrgConfigScope(saman, []domain.Tenant{hanmacFamily, saman}, domain.JSONMap{
		"visibility":  "private",
		"orgUnitType": "팀",
	})
	assert.NoError(t, err)

	err = validateTenantOrgConfigScope(outsider, []domain.Tenant{hanmacFamily, saman, outsider}, domain.JSONMap{
		"visibility": "private",
	})
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "only hanmac-family descendants")
}

func TestFilterPublicTenantsExcludesInternalPrivateAndDescendants(t *testing.T) {
	root := domain.Tenant{ID: "root", Slug: "hanmac-family"}
	publicTenant := domain.Tenant{ID: "public", Slug: "public", ParentID: &root.ID}
	internalTenant := domain.Tenant{ID: "internal", Slug: "internal", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "internal"}}
	privateTenant := domain.Tenant{ID: "private", Slug: "private", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "private"}}
	privateChild := domain.Tenant{ID: "private-child", Slug: "private-child", ParentID: &privateTenant.ID}

	filtered := filterPublicTenants([]domain.Tenant{
		root,
		publicTenant,
		internalTenant,
		privateTenant,
		privateChild,
	})

	assert.Equal(t, []domain.Tenant{root, publicTenant}, filtered)
}

func TestTenantHandler_ApproveTenant(t *testing.T) {
	app := fiber.New()
	mockSvc := new(MockTenantService)
	h := &TenantHandler{Service: mockSvc}

	app.Post("/tenants/:id/approve", h.ApproveTenant)

	mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil)

	req := httptest.NewRequest("POST", "/tenants/t1/approve", nil)
	resp, _ := app.Test(req)

	assert.Equal(t, http.StatusOK, resp.StatusCode)
}

func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
	args := m.Called(ctx, tenantIDs)
	return args.Error(0)
}

func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
	args := m.Called(ctx, userID)
	if args.Get(0) != nil {
		return args.Get(0).([]domain.Tenant), args.Error(1)
	}
	return nil, args.Error(1)
}
