package host_test

import (
	"context"
	"encoding/json"
	"fmt"
	"testing"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	hostdomain "hyapp/services/user-service/internal/domain/host"
	userdomain "hyapp/services/user-service/internal/domain/user"
	hostservice "hyapp/services/user-service/internal/service/host"
	"hyapp/services/user-service/internal/testutil/mysqltest"
)

type sequenceIDGenerator struct {
	next int64
}

func (g *sequenceIDGenerator) NewInt64() int64 {
	id := g.next
	g.next++
	return id
}

func newHostService(repository *mysqltest.Repository, firstID int64, nowMs int64) *hostservice.Service {
	return hostservice.New(repository.HostRepository(),
		hostservice.WithIDGenerator(&sequenceIDGenerator{next: firstID}),
		hostservice.WithClock(func() time.Time { return time.UnixMilli(nowMs) }),
	)
}

func seedActiveUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) {
	t.Helper()
	seedActiveUserInCountry(t, repository, userID, regionID, "CN", "")
}

func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64, country string, avatar string) {
	seedActiveUserInAppAndCountry(t, repository, appcode.Default, userID, regionID, country, avatar)
}

func seedActiveUserInAppAndCountry(t *testing.T, repository *mysqltest.Repository, app string, userID int64, regionID int64, country string, avatar string) {
	t.Helper()
	repository.PutUser(userdomain.User{
		AppCode:              app,
		UserID:               userID,
		DefaultDisplayUserID: displayID(userID),
		CurrentDisplayUserID: displayID(userID),
		Country:              country,
		RegionID:             regionID,
		Avatar:               avatar,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
}

// assertRoleInvitationCreatedOutbox verifies the reliable boundary consumed by
// activity-service. A pending invitation without this row would exist in MySQL
// but never reach the target user's Tencent IM conversation.
func assertRoleInvitationCreatedOutbox(t *testing.T, repository *mysqltest.Repository, invitation hostdomain.RoleInvitation) {
	t.Helper()
	var payloadJSON string
	if err := repository.RawDB().QueryRow(`
		SELECT CAST(payload_json AS CHAR)
		FROM user_outbox
		WHERE app_code = ? AND event_type = 'RoleInvitationCreated'
		  AND aggregate_type = 'role_invitation' AND aggregate_id = ?`,
		appcode.Default, invitation.InvitationID,
	).Scan(&payloadJSON); err != nil {
		t.Fatalf("query RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
	}
	var payload struct {
		InvitationID           int64  `json:"invitation_id"`
		InvitationType         string `json:"invitation_type"`
		Status                 string `json:"status"`
		InviterUserID          int64  `json:"inviter_user_id"`
		TargetUserID           int64  `json:"target_user_id"`
		ExternalOperatorUserID int64  `json:"external_operator_user_id"`
		ParentOwnerUserID      int64  `json:"parent_owner_user_id"`
		ParentAgencyID         int64  `json:"parent_agency_id"`
	}
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		t.Fatalf("decode RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
	}
	if payload.InvitationID != invitation.InvitationID ||
		payload.InvitationType != invitation.InvitationType ||
		payload.Status != hostdomain.InvitationStatusPending ||
		payload.InviterUserID != invitation.InviterUserID ||
		payload.TargetUserID != invitation.TargetUserID ||
		payload.ExternalOperatorUserID != invitation.ExternalOperatorUserID ||
		payload.ParentOwnerUserID != invitation.ParentOwnerUserID ||
		payload.ParentAgencyID != invitation.ParentAgencyID {
		t.Fatalf("RoleInvitationCreated outbox lost external invitation identity: payload=%+v invitation=%+v", payload, invitation)
	}
}

func seedActiveMembership(t *testing.T, repository *mysqltest.Repository, membershipID int64, agencyID int64, hostUserID int64, membershipType string) {
	t.Helper()
	if membershipType == "" {
		membershipType = hostdomain.MembershipTypeMember
	}
	_, err := repository.RawDB().Exec(`
		INSERT INTO agency_memberships (
			membership_id, agency_id, host_user_id, membership_type, status,
			joined_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
	`, membershipID, agencyID, hostUserID, membershipType, hostdomain.MembershipStatusActive)
	if err != nil {
		t.Fatalf("seed active membership %d failed: %v", membershipID, err)
	}
}

func displayID(userID int64) string {
	return fmt.Sprintf("%06d", 900000+userID%100000)
}

func assertNoHostOrgRegionSnapshotColumns(t *testing.T, repository *mysqltest.Repository) {
	t.Helper()
	var count int64
	err := repository.RawDB().QueryRow(`
		SELECT COUNT(*)
		FROM information_schema.COLUMNS
		WHERE TABLE_SCHEMA = DATABASE()
		  AND COLUMN_NAME = 'region_id'
		  AND TABLE_NAME IN (
		    'host_profiles',
		    'manager_profiles',
		    'bd_profiles',
		    'bd_leader_profiles',
		    'agencies',
		    'agency_memberships',
		    'agency_applications',
		    'role_invitations'
		  )
	`).Scan(&count)
	if err != nil {
		t.Fatalf("check host org region snapshot columns failed: %v", err)
	}
	if count != 0 {
		t.Fatalf("host org identity tables must not keep region_id snapshot columns: count=%d", count)
	}
}

func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 101, 10)
	seedActiveUser(t, repository, 202, 10)
	seedActiveUser(t, repository, 303, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    301,
		OwnerUserID: 202,
		RegionID:    10,
		Name:        "Agency Owner Is Not Manager",
		Status:      hostdomain.AgencyStatusActive,
	})
	repository.PutManagerProfile(hostdomain.ManagerProfile{
		UserID: 101,
		Status: hostdomain.ManagerStatusActive,
	})
	repository.PutManagerProfile(hostdomain.ManagerProfile{
		UserID: 303,
		Status: hostdomain.ManagerStatusDisabled,
	})
	svc := newHostService(repository, 1000, 2000)

	allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant)
	if err != nil {
		t.Fatalf("CheckBusinessCapability for manager failed: %v", err)
	}
	if !allowed || reason != "" {
		t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason)
	}

	allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
	if err != nil || allowed || reason != "manager_capability_required" {
		t.Fatalf("region expansion must default closed: allowed=%v reason=%q err=%v", allowed, reason, err)
	}
	repository.SetManagerCrossRegion(101, true)
	allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
	if err != nil || !allowed || reason != "" {
		t.Fatalf("enabled region expansion should be allowed: allowed=%v reason=%q err=%v", allowed, reason, err)
	}

	repository.SetManagerGrantVIP(101, false)
	allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP)
	if err != nil {
		t.Fatalf("CheckBusinessCapability for vip grant failed: %v", err)
	}
	if allowed || reason != "manager_capability_required" {
		t.Fatalf("manager without vip grant should be denied: allowed=%v reason=%q", allowed, reason)
	}

	allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant)
	if err != nil {
		t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err)
	}
	if allowed || reason != "manager_capability_required" {
		t.Fatalf("agency owner without manager profile should be denied: allowed=%v reason=%q", allowed, reason)
	}

	allowed, reason, err = svc.CheckBusinessCapability(ctx, 303, hostservice.CapabilityManagerCenter)
	if err != nil {
		t.Fatalf("CheckBusinessCapability for disabled manager failed: %v", err)
	}
	if allowed || reason != "active_manager_required" {
		t.Fatalf("disabled manager should be denied: allowed=%v reason=%q", allowed, reason)
	}

	allowed, _, err = svc.CheckBusinessCapability(ctx, 101, "unknown_capability")
	if !xerr.IsCode(err, xerr.InvalidArgument) || allowed {
		t.Fatalf("unknown capability must fail closed: allowed=%v err=%v", allowed, err)
	}
}

func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 101, 10)
	seedActiveUser(t, repository, 202, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:       301,
		OwnerUserID:    101,
		RegionID:       10,
		ParentBDUserID: 501,
		Name:           "Phase One Agency",
		Status:         hostdomain.AgencyStatusActive,
		JoinEnabled:    true,
		CreatedAtMs:    100,
		UpdatedAtMs:    100,
	})
	svc := newHostService(repository, 1000, 2000)

	application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "apply-202-301",
		UserID:    202,
		AgencyID:  301,
	})
	if err != nil {
		t.Fatalf("ApplyToAgency failed: %v", err)
	}
	if application.Status != hostdomain.ApplicationStatusPending || application.RegionID != 10 {
		t.Fatalf("application mismatch: %+v", application)
	}

	retriedApplication, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "apply-202-301",
		UserID:    202,
		AgencyID:  301,
	})
	if err != nil {
		t.Fatalf("retry ApplyToAgency failed: %v", err)
	}
	if retriedApplication.ApplicationID != application.ApplicationID {
		t.Fatalf("apply retry must return same application: got %d want %d", retriedApplication.ApplicationID, application.ApplicationID)
	}

	result, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
		CommandID:      "review-approve-202-301",
		ReviewerUserID: 101,
		ApplicationID:  application.ApplicationID,
		Decision:       hostdomain.ApplicationDecisionApprove,
	})
	if err != nil {
		t.Fatalf("ReviewAgencyApplication approve failed: %v", err)
	}
	if result.Application.Status != hostdomain.ApplicationStatusApproved {
		t.Fatalf("approved application mismatch: %+v", result.Application)
	}
	if result.HostProfile.UserID != 202 || result.HostProfile.CurrentAgencyID != 301 || result.Membership.Status != hostdomain.MembershipStatusActive {
		t.Fatalf("host membership facts mismatch: profile=%+v membership=%+v", result.HostProfile, result.Membership)
	}

	retriedReview, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
		CommandID:      "review-approve-202-301",
		ReviewerUserID: 101,
		ApplicationID:  application.ApplicationID,
		Decision:       hostdomain.ApplicationDecisionApprove,
	})
	if err != nil {
		t.Fatalf("retry ReviewAgencyApplication failed: %v", err)
	}
	if retriedReview.Membership.MembershipID != result.Membership.MembershipID {
		t.Fatalf("review retry must return same membership: got %d want %d", retriedReview.Membership.MembershipID, result.Membership.MembershipID)
	}

	_, err = svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "apply-second-active-member",
		UserID:    202,
		AgencyID:  301,
	})
	if got := xerr.CodeOf(err); got != xerr.Conflict {
		t.Fatalf("active member must not create another application: got %s err=%v", got, err)
	}
}

func TestDefaultAppHostApplicationStillRejectsCrossRegionAtApplyAndReview(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 101, 10)
	seedActiveUser(t, repository, 202, 20)
	seedActiveUser(t, repository, 203, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    301,
		OwnerUserID: 101,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	svc := newHostService(repository, 1_500, 2_500)

	if _, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "lalu-apply-cross-region",
		UserID:    202,
		AgencyID:  301,
	}); xerr.CodeOf(err) != xerr.PermissionDenied {
		t.Fatalf("default App must keep apply-time region boundary: %v", err)
	}
	application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "lalu-apply-before-region-drift",
		UserID:    203,
		AgencyID:  301,
	})
	if err != nil {
		t.Fatalf("same-region application setup failed: %v", err)
	}
	seedActiveUser(t, repository, 203, 20)
	if _, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
		CommandID:      "lalu-review-after-region-drift",
		ReviewerUserID: 101,
		ApplicationID:  application.ApplicationID,
		Decision:       hostdomain.ApplicationDecisionApprove,
	}); xerr.CodeOf(err) != xerr.PermissionDenied {
		t.Fatalf("default App must keep review-time region boundary: %v", err)
	}
}

func TestAcceptAgencyInvitationCreatesOwnerHostAndPreservesExistingHostBinding(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 501, 10)
	seedActiveUser(t, repository, 601, 10)
	seedActiveUser(t, repository, 602, 10)
	seedActiveUser(t, repository, 603, 10)
	seedActiveUser(t, repository, 604, 10)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   501,
		Role:     hostdomain.BDRoleBD,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    650,
		OwnerUserID: 604,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	svc := newHostService(repository, 2000, 3000)

	invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "invite-agency-601",
		InviterUserID: 501,
		TargetUserID:  601,
		AgencyName:    "Owner Agency",
	})
	if err != nil {
		t.Fatalf("InviteAgency failed: %v", err)
	}
	if invitation.Status != hostdomain.InvitationStatusPending ||
		invitation.InvitationType != hostdomain.InvitationTypeAgency ||
		invitation.ExternalOperatorUserID != 0 ||
		invitation.ParentOwnerUserID != 0 ||
		invitation.ParentAgencyID != 0 {
		t.Fatalf("agency invitation should wait for target accept: %+v", invitation)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-agency-601",
		ActorUserID:  601,
		InvitationID: invitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("ProcessRoleInvitation accept agency failed: %v", err)
	}
	summary, err := svc.GetUserRoleSummary(ctx, 601)
	if err != nil {
		t.Fatalf("GetUserRoleSummary failed: %v", err)
	}
	agency, err := svc.GetAgency(ctx, summary.AgencyID)
	if err != nil {
		t.Fatalf("GetAgency failed: %v", err)
	}
	hostProfile, err := svc.GetHostProfile(ctx, 601)
	if err != nil {
		t.Fatalf("GetHostProfile failed: %v", err)
	}
	if !summary.IsAgency || !summary.IsHost || agency.OwnerUserID != 601 || hostProfile.CurrentAgencyID != agency.AgencyID {
		t.Fatalf("owner agency facts mismatch: summary=%+v agency=%+v host=%+v", summary, agency, hostProfile)
	}
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:   602,
		Status:   hostdomain.HostStatusActive,
		RegionID: 10,
		Source:   "test",
	})
	secondInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "invite-agency-602",
		InviterUserID: 501,
		TargetUserID:  602,
		AgencyName:    "Reused Host Agency",
	})
	if err != nil {
		t.Fatalf("detached active host should become agency owner: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-agency-602",
		ActorUserID:  602,
		InvitationID: secondInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("ProcessRoleInvitation accept detached host agency failed: %v", err)
	}
	secondSummary, err := svc.GetUserRoleSummary(ctx, 602)
	if err != nil {
		t.Fatalf("GetUserRoleSummary for reused host failed: %v", err)
	}
	secondHostProfile, err := svc.GetHostProfile(ctx, 602)
	if err != nil {
		t.Fatalf("GetHostProfile for reused host failed: %v", err)
	}
	if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusPending {
		t.Fatalf("detached host reuse mismatch: summary=%+v host=%+v invitation=%+v", secondSummary, secondHostProfile, secondInvitation)
	}
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              603,
		Status:              hostdomain.HostStatusActive,
		RegionID:            10,
		CurrentAgencyID:     650,
		CurrentMembershipID: 660,
		Source:              "existing-membership",
	})
	seedActiveMembership(t, repository, 660, 650, 603, hostdomain.MembershipTypeMember)
	boundHostInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "invite-agency-603-bound-host",
		InviterUserID: 501,
		TargetUserID:  603,
		AgencyName:    "Bound Host Agency",
	})
	if err != nil {
		t.Fatalf("active bound host should still be invitable as agency owner: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-agency-603-bound-host",
		ActorUserID:  603,
		InvitationID: boundHostInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("ProcessRoleInvitation accept bound host agency failed: %v", err)
	}
	boundHostProfile, err := svc.GetHostProfile(ctx, 603)
	if err != nil {
		t.Fatalf("GetHostProfile for bound host failed: %v", err)
	}
	boundSummary, err := svc.GetUserRoleSummary(ctx, 603)
	if err != nil {
		t.Fatalf("GetUserRoleSummary for bound host failed: %v", err)
	}
	if !boundSummary.IsAgency || !boundSummary.IsHost || boundHostProfile.CurrentAgencyID != 650 || boundHostProfile.CurrentMembershipID != 660 {
		t.Fatalf("agency owner must not overwrite existing host binding: summary=%+v host=%+v", boundSummary, boundHostProfile)
	}
}

func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 611, 10)
	seedActiveUser(t, repository, 612, 10)
	seedActiveUser(t, repository, 613, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    620,
		OwnerUserID: 611,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              611,
		Status:              hostdomain.HostStatusActive,
		RegionID:            10,
		CurrentAgencyID:     620,
		CurrentMembershipID: 621,
		Source:              hostdomain.HostSourceAgencyInvitation,
	})
	svc := newHostService(repository, 630, 640)

	invitation, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
		CommandID:     "invite-host-612",
		InviterUserID: 611,
		TargetUserID:  612,
	})
	if err != nil {
		t.Fatalf("InviteHost failed: %v", err)
	}
	if invitation.InvitationType != hostdomain.InvitationTypeHost ||
		invitation.ExternalOperatorUserID != 0 ||
		invitation.ParentOwnerUserID != 0 ||
		invitation.ParentAgencyID != 0 {
		t.Fatalf("ordinary Host invitation must not write external ownership metadata: %+v", invitation)
	}
	result, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-host-612",
		ActorUserID:  612,
		InvitationID: invitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	})
	if err != nil {
		t.Fatalf("ProcessRoleInvitation accept host failed: %v", err)
	}
	if result.Membership.MembershipType != hostdomain.MembershipTypeMember || result.HostProfile.Source != hostdomain.HostSourceHostInvitation {
		t.Fatalf("host invitation should create member-only host facts: result=%+v", result)
	}
	summary, err := svc.GetUserRoleSummary(ctx, 612)
	if err != nil {
		t.Fatalf("GetUserRoleSummary host member failed: %v", err)
	}
	if !summary.IsHost || summary.IsAgency || summary.AgencyID != 0 {
		t.Fatalf("ordinary host member must not expose agency identity: %+v", summary)
	}

	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              613,
		Status:              hostdomain.HostStatusActive,
		RegionID:            10,
		CurrentAgencyID:     620,
		CurrentMembershipID: 622,
		Source:              hostdomain.HostSourceAgencyInvitation,
	})
	legacySummary, err := svc.GetUserRoleSummary(ctx, 613)
	if err != nil {
		t.Fatalf("GetUserRoleSummary legacy host member failed: %v", err)
	}
	if !legacySummary.IsHost || legacySummary.IsAgency || legacySummary.AgencyID != 0 {
		t.Fatalf("legacy host member source must not imply agency identity: %+v", legacySummary)
	}

	ownerSummary, err := svc.GetUserRoleSummary(ctx, 611)
	if err != nil {
		t.Fatalf("GetUserRoleSummary owner failed: %v", err)
	}
	if !ownerSummary.IsHost || !ownerSummary.IsAgency || ownerSummary.AgencyID != 620 {
		t.Fatalf("agency owner should keep agency identity: %+v", ownerSummary)
	}
}

func TestKickAgencyHostOnlyEndsMembershipAndKeepsHostIdentity(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 621, 10)
	seedActiveUser(t, repository, 622, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    623,
		OwnerUserID: 621,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              622,
		Status:              hostdomain.HostStatusActive,
		RegionID:            10,
		CurrentAgencyID:     623,
		CurrentMembershipID: 624,
		Source:              hostdomain.HostSourceHostInvitation,
	})
	seedActiveMembership(t, repository, 624, 623, 622, hostdomain.MembershipTypeMember)
	svc := newHostService(repository, 625, 640)

	result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{
		CommandID:      "kick-host-622",
		OperatorUserID: 621,
		AgencyID:       623,
		HostUserID:     622,
		Reason:         "agency_remove",
	})
	if err != nil {
		t.Fatalf("KickAgencyHost failed: %v", err)
	}
	if result.Membership.Status != hostdomain.MembershipStatusEnded || result.Membership.EndedAtMs != 640 || result.Membership.EndedByUserID != 621 || result.Membership.EndedReason != "agency_remove" {
		t.Fatalf("membership should be ended by agency owner: %+v", result.Membership)
	}
	if result.HostProfile.Status != hostdomain.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 {
		t.Fatalf("removed host should keep identity and only lose agency binding: %+v", result.HostProfile)
	}
	summary, err := svc.GetUserRoleSummary(ctx, 622)
	if err != nil {
		t.Fatalf("GetUserRoleSummary after kick failed: %v", err)
	}
	if !summary.IsHost || summary.HostStatus != hostdomain.HostStatusActive || summary.IsAgency {
		t.Fatalf("removed host should keep host entry without agency identity: %+v", summary)
	}
	replayed, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{
		CommandID:      "kick-host-622",
		OperatorUserID: 621,
		AgencyID:       623,
		HostUserID:     622,
		Reason:         "agency_remove",
	})
	if err != nil {
		t.Fatalf("KickAgencyHost idempotent replay failed: %v", err)
	}
	if replayed.HostProfile.Status != hostdomain.HostStatusActive || replayed.HostProfile.CurrentAgencyID != 0 || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded {
		t.Fatalf("idempotent replay should return unbound active host fact: %+v", replayed)
	}
}

func TestKickAgencyHostKeepsHostIdentityForActiveAgencyOwner(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 631, 10)
	seedActiveUser(t, repository, 632, 10)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    633,
		OwnerUserID: 631,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    635,
		OwnerUserID: 632,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              632,
		Status:              hostdomain.HostStatusActive,
		RegionID:            10,
		CurrentAgencyID:     633,
		CurrentMembershipID: 634,
		Source:              hostdomain.HostSourceHostInvitation,
	})
	seedActiveMembership(t, repository, 634, 633, 632, hostdomain.MembershipTypeMember)
	svc := newHostService(repository, 636, 650)

	result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{
		CommandID:      "kick-host-owner-632",
		OperatorUserID: 631,
		AgencyID:       633,
		HostUserID:     632,
		Reason:         "agency_remove",
	})
	if err != nil {
		t.Fatalf("KickAgencyHost for active agency owner failed: %v", err)
	}
	if result.HostProfile.Status != hostdomain.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 {
		t.Fatalf("active agency owner should keep host identity but drop removed membership pointer: %+v", result.HostProfile)
	}
	summary, err := svc.GetUserRoleSummary(ctx, 632)
	if err != nil {
		t.Fatalf("GetUserRoleSummary for active agency owner failed: %v", err)
	}
	if !summary.IsHost || !summary.IsAgency || summary.AgencyID != 635 {
		t.Fatalf("active agency owner should still expose host and agency entries: %+v", summary)
	}
}

func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 701, 10)
	seedActiveUser(t, repository, 702, 10)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   701,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	svc := newHostService(repository, 3000, 4000)

	invitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-702",
		InviterUserID: 701,
		TargetUserID:  702,
	})
	if err != nil {
		t.Fatalf("InviteBD failed: %v", err)
	}
	if invitation.Status != hostdomain.InvitationStatusPending ||
		invitation.InvitationType != hostdomain.InvitationTypeBD ||
		invitation.ExternalOperatorUserID != 0 ||
		invitation.ParentOwnerUserID != 0 ||
		invitation.ParentAgencyID != 0 {
		t.Fatalf("bd invitation should wait for target accept: %+v", invitation)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-bd-702",
		ActorUserID:  702,
		InvitationID: invitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("ProcessRoleInvitation accept bd failed: %v", err)
	}
	bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD)
	if err != nil {
		t.Fatalf("GetBDProfile failed: %v", err)
	}
	if bdProfile.UserID != 702 || bdProfile.Role != hostdomain.BDRoleBD ||
		bdProfile.ParentLeaderUserID != 701 || bdProfile.ParentOwnerUserID != 0 {
		t.Fatalf("bd profile mismatch: %+v", bdProfile)
	}
	if _, err := svc.GetHostProfile(ctx, 702); xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("becoming bd must not create host profile: err=%v", err)
	}
}

func TestRoleInvitationCreateRejectsCrossRegionWithRegionMismatch(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 711, 10)
	seedActiveUser(t, repository, 712, 20)
	seedActiveUser(t, repository, 721, 10)
	seedActiveUser(t, repository, 722, 20)
	seedActiveUser(t, repository, 731, 10)
	seedActiveUser(t, repository, 732, 20)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   711,
		Role:     hostdomain.BDRoleBD,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   721,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    733,
		OwnerUserID: 731,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	svc := newHostService(repository, 5000, 6000)

	// 三类 H5 邀请共用 RoleInvitation 事实；跨区属于明确业务边界，不应该退化成泛化权限拒绝。
	if _, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "invite-agency-cross-region",
		InviterUserID: 711,
		TargetUserID:  712,
		AgencyName:    "Cross Region Agency",
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("cross-region agency invite should return REGION_MISMATCH: %v", err)
	}
	if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-cross-region",
		InviterUserID: 721,
		TargetUserID:  722,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("cross-region bd invite should return REGION_MISMATCH: %v", err)
	}
	if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
		CommandID:     "invite-host-cross-region",
		InviterUserID: 731,
		TargetUserID:  732,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("cross-region host invite should return REGION_MISMATCH: %v", err)
	}
}

func TestHuwaaAgencyAndBDInvitationsAllowCrossRegionAtCreateAndAccept(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), "huwaa")
	repository := mysqltest.NewRepository(t)
	for _, region := range []userdomain.Region{
		{AppCode: "huwaa", RegionID: 10, RegionCode: "R10", Name: "Region 10", Status: userdomain.RegionStatusActive},
		{AppCode: "huwaa", RegionID: 20, RegionCode: "R20", Name: "Region 20", Status: userdomain.RegionStatusActive},
	} {
		repository.PutRegion(region)
	}
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 711, 10, "US", "")
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 712, 20, "CA", "")
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 722, 20, "GB", "")

	// 先通过正式写入口建立 inviter 的 Leader + 普通 BD 双身份，后续两类邀请都使用同一套真实权限事实。
	setupSvc := newHostService(repository, 20_000, 21_000)
	if _, err := setupSvc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
		CommandID:    "huwaa-create-cross-region-inviter",
		AdminUserID:  1,
		TargetUserID: 711,
	}); err != nil {
		t.Fatalf("CreateBDLeader inviter failed: %v", err)
	}
	svc := newHostService(repository, 21_000, 22_000)

	agencyInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "huwaa-invite-agency-cross-region",
		InviterUserID: 711,
		TargetUserID:  712,
		AgencyName:    "Huwaa Cross Region Agency",
	})
	if err != nil {
		t.Fatalf("Huwaa InviteAgency should allow cross-region target: %v", err)
	}
	agencyResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "huwaa-accept-agency-cross-region",
		ActorUserID:  712,
		InvitationID: agencyInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	})
	if err != nil {
		t.Fatalf("Huwaa agency invitation accept should allow cross-region organization link: %v", err)
	}
	if agencyResult.Agency.RegionID != 20 || agencyResult.Agency.ParentBDUserID != 711 {
		t.Fatalf("cross-region agency must keep target region and inviter relationship: %+v", agencyResult.Agency)
	}
	// 产品只放开 Host 主动申请；Agency owner 主动邀请 Host 仍沿用原区域边界，避免把例外扩散到未授权流程。
	if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
		CommandID:     "huwaa-invite-host-cross-region-still-blocked",
		InviterUserID: 712,
		TargetUserID:  711,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("Huwaa Agency-to-Host invitation must remain region-scoped: %v", err)
	}

	bdInvitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "huwaa-invite-bd-cross-region",
		InviterUserID: 711,
		TargetUserID:  722,
	})
	if err != nil {
		t.Fatalf("Huwaa InviteBD should allow cross-region target: %v", err)
	}
	bdResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "huwaa-accept-bd-cross-region",
		ActorUserID:  722,
		InvitationID: bdInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	})
	if err != nil {
		t.Fatalf("Huwaa BD invitation accept should allow cross-region leader link: %v", err)
	}
	if bdResult.BDProfile.RegionID != 20 || bdResult.BDProfile.ParentLeaderUserID != 711 {
		t.Fatalf("cross-region BD must keep target region and inviter leader: %+v", bdResult.BDProfile)
	}
}

func TestHuwaaHostApplicationSearchAndReviewAllowCrossRegion(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), "huwaa")
	repository := mysqltest.NewRepository(t)
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 731, 10, "US", "")
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 732, 20, "CA", "")
	svc := newHostService(repository, 22_000, 23_000)

	created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:   "huwaa-create-host-application-agency",
		AdminUserID: 1,
		OwnerUserID: 731,
		Name:        "Huwaa Global Agency",
		JoinEnabled: true,
	})
	if err != nil {
		t.Fatalf("CreateAgency failed: %v", err)
	}
	agencies, err := svc.SearchAgencies(ctx, 732, "", 20)
	if err != nil {
		t.Fatalf("Huwaa SearchAgencies failed: %v", err)
	}
	if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
		t.Fatalf("Huwaa host applicant must discover cross-country agency: %+v", agencies)
	}

	application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
		CommandID: "huwaa-apply-host-cross-region",
		UserID:    732,
		AgencyID:  created.Agency.AgencyID,
	})
	if err != nil {
		t.Fatalf("Huwaa host application should allow cross-region agency: %v", err)
	}
	result, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
		CommandID:      "huwaa-review-host-cross-region",
		ReviewerUserID: 731,
		ApplicationID:  application.ApplicationID,
		Decision:       hostdomain.ApplicationDecisionApprove,
	})
	if err != nil {
		t.Fatalf("Huwaa host application review should preserve cross-region relationship: %v", err)
	}
	if result.Membership.RegionID != 20 || result.Membership.AgencyID != created.Agency.AgencyID || result.Application.Status != hostdomain.ApplicationStatusApproved {
		t.Fatalf("cross-region host application facts mismatch: %+v", result)
	}
}

func TestRoleInvitationAcceptRejectsRegionDriftWithRegionMismatch(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 741, 10)
	seedActiveUser(t, repository, 742, 10)
	seedActiveUser(t, repository, 751, 10)
	seedActiveUser(t, repository, 752, 10)
	seedActiveUser(t, repository, 761, 10)
	seedActiveUser(t, repository, 762, 10)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   741,
		Role:     hostdomain.BDRoleBD,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   751,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    763,
		OwnerUserID: 761,
		RegionID:    10,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	svc := newHostService(repository, 5100, 6100)

	agencyInvite, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
		CommandID:     "invite-agency-before-drift",
		InviterUserID: 741,
		TargetUserID:  742,
		AgencyName:    "Drift Agency",
	})
	if err != nil {
		t.Fatalf("InviteAgency before region drift failed: %v", err)
	}
	bdInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-before-drift",
		InviterUserID: 751,
		TargetUserID:  752,
	})
	if err != nil {
		t.Fatalf("InviteBD before region drift failed: %v", err)
	}
	hostInvite, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
		CommandID:     "invite-host-before-drift",
		InviterUserID: 761,
		TargetUserID:  762,
	})
	if err != nil {
		t.Fatalf("InviteHost before region drift failed: %v", err)
	}

	// 邀请发出后用户可能被后台迁区；接受时必须重新读 users 当前区域，并给 H5 可解释的跨区错误。
	seedActiveUser(t, repository, 742, 20)
	seedActiveUser(t, repository, 752, 20)
	seedActiveUser(t, repository, 762, 20)
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-agency-after-drift",
		ActorUserID:  742,
		InvitationID: agencyInvite.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("agency accept after region drift should return REGION_MISMATCH: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-bd-after-drift",
		ActorUserID:  752,
		InvitationID: bdInvite.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("bd accept after region drift should return REGION_MISMATCH: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-host-after-drift",
		ActorUserID:  762,
		InvitationID: hostInvite.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("host accept after region drift should return REGION_MISMATCH: %v", err)
	}
}

func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 801, 10)
	seedActiveUser(t, repository, 802, 20)
	seedActiveUser(t, repository, 803, 30)
	repository.PutHostProfile(hostdomain.HostProfile{UserID: 801, Status: hostdomain.HostStatusActive, Source: "batch-test"})
	repository.PutHostProfile(hostdomain.HostProfile{UserID: 802, Status: hostdomain.HostStatusDisabled, Source: "batch-test"})
	svc := newHostService(repository, 4000, 5000)

	profiles, err := svc.BatchGetHostProfiles(ctx, []int64{801, 802, 803, 801})
	if err != nil {
		t.Fatalf("BatchGetHostProfiles failed: %v", err)
	}
	if len(profiles) != 2 {
		t.Fatalf("batch host profiles should only include existing rows, got %+v", profiles)
	}
	if profiles[801].UserID != 801 || profiles[801].Status != hostdomain.HostStatusActive || profiles[801].RegionID != 10 {
		t.Fatalf("active host profile mismatch: %+v", profiles[801])
	}
	if profiles[802].UserID != 802 || profiles[802].Status != hostdomain.HostStatusDisabled || profiles[802].RegionID != 20 {
		t.Fatalf("disabled host profile should still be returned for gateway-side filtering: %+v", profiles[802])
	}
	if _, ok := profiles[803]; ok {
		t.Fatalf("missing host profile must not be returned as placeholder: %+v", profiles[803])
	}
}

func TestBatchGetHostProfilesRejectsInvalidUserID(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := newHostService(repository, 4000, 5000)

	_, err := svc.BatchGetHostProfiles(ctx, []int64{801, 0})
	if xerr.CodeOf(err) != xerr.InvalidArgument {
		t.Fatalf("invalid batch user id should fail with INVALID_ARGUMENT: %v", err)
	}
}

func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 703, 10)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   703,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 10,
		Status:   hostdomain.BDStatusActive,
	})
	svc := newHostService(repository, 3100, 4100)

	invitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-self-703",
		InviterUserID: 703,
		TargetUserID:  703,
	})
	if err != nil {
		t.Fatalf("bd leader self invite must be created: %v", err)
	}
	if invitation.Status != hostdomain.InvitationStatusPending || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 {
		t.Fatalf("self invite invitation mismatch: %+v", invitation)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-bd-self-703",
		ActorUserID:  703,
		InvitationID: invitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("self invite accept must create ordinary bd profile: %v", err)
	}
	ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD)
	if err != nil {
		t.Fatalf("self invite must create ordinary bd profile: %v", err)
	}
	if ordinaryBD.Role != hostdomain.BDRoleBD || ordinaryBD.ParentLeaderUserID != 703 {
		t.Fatalf("self invite ordinary bd mismatch: %+v", ordinaryBD)
	}
	leaderProfile, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleLeader)
	if err != nil {
		t.Fatalf("self invite must keep existing leader profile unchanged: %v", err)
	}
	if leaderProfile.Role != hostdomain.BDRoleLeader || leaderProfile.Status != hostdomain.BDStatusActive {
		t.Fatalf("self invite leader profile mismatch: %+v", leaderProfile)
	}
	retried, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-self-703",
		InviterUserID: 703,
		TargetUserID:  703,
	})
	if err != nil || retried.InvitationID != invitation.InvitationID {
		t.Fatalf("self invite retry must replay existing invitation: retried=%+v err=%v", retried, err)
	}
}

func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	assertNoHostOrgRegionSnapshotColumns(t, repository)
	repository.PutRegion(userdomain.Region{RegionID: 20, RegionCode: "R20", Name: "Region 20"})
	seedActiveUser(t, repository, 801, 20)
	seedActiveUser(t, repository, 802, 20)
	seedActiveUser(t, repository, 804, 20)
	svc := newHostService(repository, 4000, 5000)

	leader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
		CommandID:    "admin-create-leader-801",
		AdminUserID:  1,
		TargetUserID: 801,
		Reason:       "seed leader",
		RequestID:    "req-leader-1",
	})
	if err != nil {
		t.Fatalf("CreateBDLeader failed: %v", err)
	}
	if leader.Role != hostdomain.BDRoleLeader || leader.Status != hostdomain.BDStatusActive || leader.RegionID != 20 {
		t.Fatalf("leader mismatch: %+v", leader)
	}
	updatedUser, err := repository.GetUser(ctx, 801)
	if err != nil {
		t.Fatalf("GetUser after CreateBDLeader failed: %v", err)
	}
	if updatedUser.RegionID != 20 {
		t.Fatalf("CreateBDLeader must keep target user region as profile region: %+v", updatedUser)
	}
	retriedLeader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
		CommandID:    "admin-create-leader-801",
		AdminUserID:  1,
		TargetUserID: 801,
	})
	if err != nil {
		t.Fatalf("retry CreateBDLeader failed: %v", err)
	}
	if retriedLeader.UserID != leader.UserID || retriedLeader.RegionID != 20 {
		t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
	}

	sameUserBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD)
	if err != nil {
		t.Fatalf("CreateBDLeader must create self ordinary bd: %v", err)
	}
	if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
		t.Fatalf("self ordinary bd mismatch: %+v", sameUserBD)
	}
	_, err = svc.CreateBD(ctx, hostservice.CreateBDInput{
		CommandID:          "admin-create-bd-801-same-user",
		AdminUserID:        1,
		TargetUserID:       801,
		ParentLeaderUserID: 801,
		Reason:             "same user already has bd",
		RequestID:          "req-bd-same-user-1",
	})
	if got := xerr.CodeOf(err); got != xerr.Conflict {
		t.Fatalf("CreateBDLeader already creates ordinary bd: got %s err=%v", got, err)
	}
	leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
	if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive {
		t.Fatalf("ordinary bd creation must not rewrite leader row: leader=%+v err=%v", leaderAfterBD, err)
	}

	bd, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
		CommandID:          "admin-create-bd-802",
		AdminUserID:        1,
		TargetUserID:       802,
		ParentLeaderUserID: 801,
		Reason:             "seed bd",
		RequestID:          "req-bd-1",
	})
	if err != nil {
		t.Fatalf("CreateBD failed: %v", err)
	}
	if bd.Role != hostdomain.BDRoleBD || bd.ParentLeaderUserID != 801 || bd.RegionID != 20 {
		t.Fatalf("bd mismatch: %+v", bd)
	}
	bdAfterSnapshotDrift, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD)
	if err != nil || bdAfterSnapshotDrift.RegionID != 20 {
		t.Fatalf("bd profile must read region from users table: bd=%+v err=%v", bdAfterSnapshotDrift, err)
	}

	independentBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
		CommandID:    "admin-create-bd-804-independent",
		AdminUserID:  1,
		TargetUserID: 804,
		Reason:       "seed independent bd",
		RequestID:    "req-bd-independent-1",
	})
	if err != nil {
		t.Fatalf("CreateBD without parent leader failed: %v", err)
	}
	if independentBD.Role != hostdomain.BDRoleBD || independentBD.ParentLeaderUserID != 0 || independentBD.RegionID != 20 {
		t.Fatalf("independent bd mismatch: %+v", independentBD)
	}

	disabled, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
		CommandID:    "admin-disable-bd-802",
		AdminUserID:  1,
		TargetUserID: 802,
		Role:         hostdomain.BDRoleBD,
		Status:       hostdomain.BDStatusDisabled,
		Reason:       "stop bd",
		RequestID:    "req-bd-stop-1",
	})
	if err != nil {
		t.Fatalf("SetBDStatus disabled failed: %v", err)
	}
	if disabled.Status != hostdomain.BDStatusDisabled {
		t.Fatalf("disabled bd mismatch: %+v", disabled)
	}
	disabledInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-disabled-bd-802-again",
		InviterUserID: 801,
		TargetUserID:  802,
	})
	if err != nil {
		t.Fatalf("disabled ordinary bd should be invitable again: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-disabled-bd-802-again",
		ActorUserID:  802,
		InvitationID: disabledInvite.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("accept disabled bd invite should restore profile: %v", err)
	}
	restoredBD, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD)
	if err != nil {
		t.Fatalf("GetBDProfile restored disabled bd failed: %v", err)
	}
	if restoredBD.Status != hostdomain.BDStatusActive || restoredBD.ParentLeaderUserID != 801 {
		t.Fatalf("disabled bd invite should restore active under inviter leader: %+v", restoredBD)
	}
	seedActiveUser(t, repository, 805, 20)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:             805,
		Role:               hostdomain.BDRoleBD,
		RegionID:           20,
		ParentLeaderUserID: 801,
		Status:             hostdomain.BDStatusActive,
	})
	if _, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
		CommandID:    "admin-create-leader-existing-bd-805",
		AdminUserID:  1,
		TargetUserID: 805,
		Reason:       "promote existing bd",
		RequestID:    "req-leader-existing-bd-1",
	}); err != nil {
		t.Fatalf("CreateBDLeader for existing bd failed: %v", err)
	}
	existingBD, err := svc.GetBDProfile(ctx, 805, hostdomain.BDRoleBD)
	if err != nil {
		t.Fatalf("GetBDProfile existing bd after leader create failed: %v", err)
	}
	if existingBD.ParentLeaderUserID != 801 {
		t.Fatalf("CreateBDLeader must not overwrite active bd parent: %+v", existingBD)
	}
	disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
		CommandID:    "admin-disable-bd-801",
		AdminUserID:  1,
		TargetUserID: 801,
		Role:         hostdomain.BDRoleBD,
		Status:       hostdomain.BDStatusDisabled,
		Reason:       "stop ordinary bd only",
		RequestID:    "req-bd-stop-801",
	})
	if err != nil {
		t.Fatalf("SetBDStatus ordinary bd for same user failed: %v", err)
	}
	if disabledSameUserBD.Role != hostdomain.BDRoleBD || disabledSameUserBD.Status != hostdomain.BDStatusDisabled {
		t.Fatalf("disabled same user bd mismatch: %+v", disabledSameUserBD)
	}
	stillLeader, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
	if err != nil || stillLeader.Status != hostdomain.BDStatusActive {
		t.Fatalf("disabling ordinary bd must not affect leader: leader=%+v err=%v", stillLeader, err)
	}
	disabledLeader, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
		CommandID:    "admin-disable-leader-801",
		AdminUserID:  1,
		TargetUserID: 801,
		Role:         hostdomain.BDRoleLeader,
		Status:       hostdomain.BDStatusDisabled,
		Reason:       "stop leader only",
		RequestID:    "req-leader-stop-801",
	})
	if err != nil {
		t.Fatalf("SetBDStatus leader failed: %v", err)
	}
	if disabledLeader.Role != hostdomain.BDRoleLeader || disabledLeader.Status != hostdomain.BDStatusDisabled {
		t.Fatalf("disabled leader mismatch: %+v", disabledLeader)
	}
	ordinaryStillDisabled, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD)
	if err != nil || ordinaryStillDisabled.Status != hostdomain.BDStatusDisabled {
		t.Fatalf("disabling leader must not rewrite ordinary bd: bd=%+v err=%v", ordinaryStillDisabled, err)
	}
}

func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	repository.PutRegion(userdomain.Region{RegionID: 21, RegionCode: "R21", Name: "Region 21", Status: userdomain.RegionStatusDisabled})
	seedActiveUser(t, repository, 803, 21)
	svc := newHostService(repository, 4100, 5100)

	_, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
		CommandID:    "admin-create-leader-disabled-region",
		AdminUserID:  1,
		TargetUserID: 803,
	})
	if got := xerr.CodeOf(err); got != xerr.RegionDisabled {
		t.Fatalf("disabled region must be rejected: got %s err=%v", got, err)
	}
}

func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png")
	seedActiveUser(t, repository, 902, 30)
	seedActiveUserInCountry(t, repository, 903, 30, "US", "")
	seedActiveUser(t, repository, 900, 30)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   900,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 999,
		Status:   hostdomain.BDStatusActive,
	})
	svc := newHostService(repository, 5000, 6000)

	created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-901",
		AdminUserID:    1,
		OwnerUserID:    901,
		ParentBDUserID: 900,
		Name:           "Admin Seed Agency",
		JoinEnabled:    true,
		Reason:         "seed agency",
		RequestID:      "req-agency-1",
	})
	if err != nil {
		t.Fatalf("CreateAgency failed: %v", err)
	}
	if created.Agency.OwnerUserID != 901 || created.Agency.ParentBDUserID != 900 || !created.Agency.JoinEnabled {
		t.Fatalf("agency mismatch: %+v", created.Agency)
	}
	if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != hostdomain.HostSourceAdminCreateAgency {
		t.Fatalf("owner host profile mismatch: %+v", created.HostProfile)
	}
	if created.Membership.MembershipType != hostdomain.MembershipTypeOwner || created.Membership.Status != hostdomain.MembershipStatusActive {
		t.Fatalf("owner membership mismatch: %+v", created.Membership)
	}
	if _, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-903",
		AdminUserID:    1,
		OwnerUserID:    903,
		ParentBDUserID: 900,
		Name:           "Other Country Agency",
		JoinEnabled:    true,
		Reason:         "seed agency",
		RequestID:      "req-agency-2",
	}); err != nil {
		t.Fatalf("CreateAgency in other country failed: %v", err)
	}
	agencies, err := svc.SearchAgencies(ctx, 902, "", 20)
	if err != nil {
		t.Fatalf("SearchAgencies list failed: %v", err)
	}
	if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].Avatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 {
		t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies)
	}
	if agencies[0].RegionID != 30 {
		t.Fatalf("agency search must read region from owner users table: %+v", agencies[0])
	}
	agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
	if err != nil {
		t.Fatalf("SearchAgencies failed: %v", err)
	}
	if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
		t.Fatalf("created agency should be searchable: %+v", agencies)
	}
	agencies, err = svc.SearchAgencies(ctx, 902, displayID(901), 20)
	if err != nil {
		t.Fatalf("SearchAgencies by owner display id failed: %v", err)
	}
	if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
		t.Fatalf("created agency should be searchable by owner display id: %+v", agencies)
	}
	agencies, err = svc.SearchAgencies(ctx, 902, displayID(900), 20)
	if err != nil {
		t.Fatalf("SearchAgencies by non-owner parent BD display id failed: %v", err)
	}
	if len(agencies) != 0 {
		t.Fatalf("parent BD display id must not expand to child agencies: %+v", agencies)
	}

	disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
		CommandID:   "admin-disable-agency-join-901",
		AdminUserID: 1,
		AgencyID:    created.Agency.AgencyID,
		JoinEnabled: false,
		Reason:      "pause join",
		RequestID:   "req-agency-join-1",
	})
	if err != nil {
		t.Fatalf("SetAgencyJoinEnabled failed: %v", err)
	}
	if disabledJoin.JoinEnabled {
		t.Fatalf("agency join should be disabled: %+v", disabledJoin)
	}
	agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
	if err != nil {
		t.Fatalf("SearchAgencies after join disabled failed: %v", err)
	}
	if len(agencies) != 0 {
		t.Fatalf("join-disabled agency must not be searchable: %+v", agencies)
	}

	closed, err := svc.CloseAgency(ctx, hostservice.CloseAgencyInput{
		CommandID:   "admin-close-agency-901",
		AdminUserID: 1,
		AgencyID:    created.Agency.AgencyID,
		Reason:      "close agency",
		RequestID:   "req-agency-close-1",
	})
	if err != nil {
		t.Fatalf("CloseAgency failed: %v", err)
	}
	if closed.Status != hostdomain.AgencyStatusClosed || closed.JoinEnabled {
		t.Fatalf("closed agency mismatch: %+v", closed)
	}

	reopened, err := svc.SetAgencyStatus(ctx, hostservice.SetAgencyStatusInput{
		CommandID:   "admin-reopen-agency-901",
		AdminUserID: 1,
		AgencyID:    created.Agency.AgencyID,
		Status:      hostdomain.AgencyStatusActive,
		Reason:      "reopen agency",
		RequestID:   "req-agency-status-1",
	})
	if err != nil {
		t.Fatalf("SetAgencyStatus active failed: %v", err)
	}
	if reopened.Status != hostdomain.AgencyStatusActive || reopened.JoinEnabled {
		t.Fatalf("reopened agency should keep join disabled until admin explicitly opens it: %+v", reopened)
	}

	enabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
		CommandID:   "admin-enable-agency-join-901",
		AdminUserID: 1,
		AgencyID:    created.Agency.AgencyID,
		JoinEnabled: true,
		Reason:      "resume join",
		RequestID:   "req-agency-join-2",
	})
	if err != nil {
		t.Fatalf("SetAgencyJoinEnabled after reopen failed: %v", err)
	}
	if !enabledJoin.JoinEnabled {
		t.Fatalf("agency join should be enabled after explicit open: %+v", enabledJoin)
	}

	deleted, err := svc.DeleteAgency(ctx, hostservice.DeleteAgencyInput{
		CommandID:   "admin-delete-agency-901",
		AdminUserID: 1,
		AgencyID:    created.Agency.AgencyID,
		Reason:      "delete agency",
		RequestID:   "req-agency-delete-1",
	})
	if err != nil {
		t.Fatalf("DeleteAgency failed: %v", err)
	}
	if deleted.Status != hostdomain.AgencyStatusDeleted || deleted.JoinEnabled {
		t.Fatalf("deleted agency mismatch: %+v", deleted)
	}
	hostProfile, err := svc.GetHostProfile(ctx, 901)
	if err != nil {
		t.Fatalf("GetHostProfile after delete failed: %v", err)
	}
	if hostProfile.CurrentAgencyID != 0 || hostProfile.CurrentMembershipID != 0 || hostProfile.Status != hostdomain.HostStatusActive {
		t.Fatalf("delete agency should detach but keep host profile: %+v", hostProfile)
	}
	recreated, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-901-after-delete",
		AdminUserID:    1,
		OwnerUserID:    901,
		ParentBDUserID: 900,
		Name:           "Recreated Agency",
		JoinEnabled:    true,
		Reason:         "recreate agency",
		RequestID:      "req-agency-recreate-1",
	})
	if err != nil {
		t.Fatalf("detached owner host should be reusable after delete: %v", err)
	}
	if recreated.HostProfile.CurrentAgencyID != recreated.Agency.AgencyID || recreated.HostProfile.FirstBecameHostAtMs != hostProfile.FirstBecameHostAtMs {
		t.Fatalf("recreated agency should reuse host identity: host=%+v agency=%+v oldHost=%+v", recreated.HostProfile, recreated.Agency, hostProfile)
	}
}

func TestAgencyProfileDefaultsAndStaysIndependent(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	repository.PutUser(userdomain.User{
		UserID:               921,
		DefaultDisplayUserID: displayID(921),
		CurrentDisplayUserID: displayID(921),
		Username:             "Owner Nick",
		Avatar:               "https://cdn.example/owner-old.png",
		Country:              "CN",
		RegionID:             30,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
	seedActiveUser(t, repository, 922, 30)
	svc := newHostService(repository, 5200, 6200)

	created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:   "admin-create-agency-default-profile",
		AdminUserID: 1,
		OwnerUserID: 921,
		JoinEnabled: true,
		RequestID:   "req-agency-default-profile",
	})
	if err != nil {
		t.Fatalf("CreateAgency with default profile failed: %v", err)
	}
	if created.Agency.Name != "Owner Nick" || created.Agency.Avatar != "https://cdn.example/owner-old.png" {
		t.Fatalf("agency must copy owner profile once on create: %+v", created.Agency)
	}

	newName := "Agency New"
	newAvatar := "https://cdn.example/agency-new.png"
	updated, err := svc.UpdateAgencyProfile(ctx, hostservice.UpdateAgencyProfileInput{
		CommandID:      "agency-profile-update-owner",
		OperatorUserID: 921,
		AgencyID:       created.Agency.AgencyID,
		Name:           &newName,
		Avatar:         &newAvatar,
		RequestID:      "req-agency-profile-update-owner",
	})
	if err != nil {
		t.Fatalf("UpdateAgencyProfile by owner failed: %v", err)
	}
	if updated.Name != newName || updated.Avatar != newAvatar {
		t.Fatalf("agency profile update mismatch: %+v", updated)
	}
	owner, err := repository.GetUser(ctx, 921)
	if err != nil {
		t.Fatalf("GetUser after agency update failed: %v", err)
	}
	if owner.Username != "Owner Nick" || owner.Avatar != "https://cdn.example/owner-old.png" {
		t.Fatalf("agency profile update must not modify owner user: %+v", owner)
	}

	repository.PutUser(userdomain.User{
		UserID:               921,
		DefaultDisplayUserID: displayID(921),
		CurrentDisplayUserID: displayID(921),
		Username:             "Owner New",
		Avatar:               "https://cdn.example/owner-new.png",
		Country:              "CN",
		RegionID:             30,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
	afterUserChange, err := svc.GetAgency(ctx, created.Agency.AgencyID)
	if err != nil {
		t.Fatalf("GetAgency after owner profile change failed: %v", err)
	}
	if afterUserChange.Name != newName || afterUserChange.Avatar != newAvatar {
		t.Fatalf("owner profile change must not sync back to agency: %+v", afterUserChange)
	}

	_, err = svc.UpdateAgencyProfile(ctx, hostservice.UpdateAgencyProfileInput{
		CommandID:      "agency-profile-update-non-owner",
		OperatorUserID: 922,
		AgencyID:       created.Agency.AgencyID,
		Name:           &newName,
		RequestID:      "req-agency-profile-update-non-owner",
	})
	if got := xerr.CodeOf(err); got != xerr.PermissionDenied {
		t.Fatalf("non owner agency update must be denied: got %s err=%v", got, err)
	}
}

func TestAdminCreateAgencyWithoutParentBD(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 904, 31)
	svc := newHostService(repository, 5600, 6600)

	created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:   "admin-create-agency-904-independent",
		AdminUserID: 1,
		OwnerUserID: 904,
		Name:        "Independent Agency",
		JoinEnabled: true,
		Reason:      "seed independent agency",
		RequestID:   "req-agency-independent-1",
	})
	if err != nil {
		t.Fatalf("CreateAgency without parent bd failed: %v", err)
	}
	if created.Agency.OwnerUserID != 904 || created.Agency.ParentBDUserID != 0 || created.Agency.RegionID != 31 || !created.Agency.JoinEnabled {
		t.Fatalf("independent agency mismatch: %+v", created.Agency)
	}
	if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.Membership.MembershipType != hostdomain.MembershipTypeOwner {
		t.Fatalf("independent agency owner facts mismatch: host=%+v membership=%+v", created.HostProfile, created.Membership)
	}
}

func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 911, 40)
	seedActiveUser(t, repository, 910, 40)
	seedActiveUser(t, repository, 912, 40)
	seedActiveUser(t, repository, 913, 40)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   910,
		Role:     hostdomain.BDRoleBD,
		RegionID: 999,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:   911,
		Status:   hostdomain.HostStatusActive,
		RegionID: 40,
		Source:   "test",
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    914,
		OwnerUserID: 913,
		RegionID:    40,
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: true,
	})
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:              912,
		Status:              hostdomain.HostStatusActive,
		RegionID:            40,
		CurrentAgencyID:     914,
		CurrentMembershipID: 915,
		Source:              "existing-membership",
	})
	seedActiveMembership(t, repository, 915, 914, 912, hostdomain.MembershipTypeMember)
	svc := newHostService(repository, 6000, 7000)

	created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-existing-host",
		AdminUserID:    1,
		OwnerUserID:    911,
		ParentBDUserID: 910,
		Name:           "Reused Host Agency",
		JoinEnabled:    true,
	})
	if err != nil {
		t.Fatalf("detached host should become agency owner: %v", err)
	}
	if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != "test" {
		t.Fatalf("existing host identity should be reused without rewriting source: %+v", created.HostProfile)
	}
	boundOwner, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-bound-host",
		AdminUserID:    1,
		OwnerUserID:    912,
		ParentBDUserID: 910,
		Name:           "Bound Host Agency",
		JoinEnabled:    true,
	})
	if err != nil {
		t.Fatalf("bound host should become agency owner without rebinding host: %v", err)
	}
	if boundOwner.HostProfile.CurrentAgencyID != 914 || boundOwner.HostProfile.CurrentMembershipID != 915 || boundOwner.Membership.AgencyID != 914 {
		t.Fatalf("admin agency create must preserve existing host binding: %+v", boundOwner)
	}
	boundSummary, err := svc.GetUserRoleSummary(ctx, 912)
	if err != nil {
		t.Fatalf("GetUserRoleSummary for bound agency owner failed: %v", err)
	}
	if !boundSummary.IsAgency || !boundSummary.IsHost || boundSummary.AgencyID != boundOwner.Agency.AgencyID {
		t.Fatalf("bound host agency owner summary mismatch: %+v agency=%+v", boundSummary, boundOwner.Agency)
	}
	_, err = svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
		CommandID:      "admin-create-agency-existing-host-again",
		AdminUserID:    1,
		OwnerUserID:    911,
		ParentBDUserID: 910,
		Name:           "Blocked Agency",
		JoinEnabled:    true,
	})
	if got := xerr.CodeOf(err); got != xerr.Conflict {
		t.Fatalf("host with active membership must not become another agency owner: got %s err=%v", got, err)
	}
}

func TestAdminAddAgencyHostCreatesMembershipWithoutInvite(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 916, 41)
	seedActiveUser(t, repository, 917, 41)
	seedActiveUser(t, repository, 918, 42)
	seedActiveUser(t, repository, 919, 41)
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    920,
		OwnerUserID: 916,
		RegionID:    41,
		Name:        "Admin Add Host Agency",
		Status:      hostdomain.AgencyStatusActive,
		JoinEnabled: false,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    921,
		OwnerUserID: 918,
		RegionID:    42,
		Name:        "Other Region Agency",
		Status:      hostdomain.AgencyStatusActive,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:    922,
		OwnerUserID: 919,
		RegionID:    41,
		Name:        "Closed Agency",
		Status:      hostdomain.AgencyStatusClosed,
	})
	svc := newHostService(repository, 6500, 7500)

	added, err := svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{
		CommandID:    "admin-add-host-917",
		AdminUserID:  1,
		AgencyID:     920,
		TargetUserID: 917,
		Reason:       "manual add",
		RequestID:    "req-admin-add-host-1",
	})
	if err != nil {
		t.Fatalf("AdminAddAgencyHost failed: %v", err)
	}
	if added.Agency.AgencyID != 920 || added.Membership.AgencyID != 920 || added.Membership.HostUserID != 917 || added.Membership.MembershipType != hostdomain.MembershipTypeMember {
		t.Fatalf("added host membership mismatch: %+v", added)
	}
	if added.HostProfile.CurrentAgencyID != 920 || added.HostProfile.CurrentMembershipID != added.Membership.MembershipID || added.HostProfile.Source != hostdomain.HostSourceAdminAddHost {
		t.Fatalf("admin-added host profile mismatch: %+v", added.HostProfile)
	}
	replayed, err := svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{
		CommandID:    "admin-add-host-917",
		AdminUserID:  1,
		AgencyID:     920,
		TargetUserID: 917,
	})
	if err != nil {
		t.Fatalf("AdminAddAgencyHost replay failed: %v", err)
	}
	if replayed.Membership.MembershipID != added.Membership.MembershipID || replayed.HostProfile.CurrentMembershipID != added.Membership.MembershipID {
		t.Fatalf("admin add replay should return original facts: replay=%+v added=%+v", replayed, added)
	}

	_, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{
		CommandID:    "admin-add-host-917-again",
		AdminUserID:  1,
		AgencyID:     921,
		TargetUserID: 917,
	})
	if got := xerr.CodeOf(err); got != xerr.RegionMismatch && got != xerr.Conflict {
		t.Fatalf("bound host must not be silently moved: got %s err=%v", got, err)
	}
	_, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{
		CommandID:    "admin-add-host-cross-region",
		AdminUserID:  1,
		AgencyID:     920,
		TargetUserID: 918,
	})
	if got := xerr.CodeOf(err); got != xerr.RegionMismatch {
		t.Fatalf("cross-region add must be rejected: got %s err=%v", got, err)
	}
	_, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{
		CommandID:    "admin-add-host-closed-agency",
		AdminUserID:  1,
		AgencyID:     922,
		TargetUserID: 919,
	})
	if got := xerr.CodeOf(err); got != xerr.Conflict {
		t.Fatalf("closed agency must be rejected: got %s err=%v", got, err)
	}
}

func TestAdminCreateCoinSellerCanCoexistWithBDAndAgency(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 921, 50)
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   921,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 50,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:       922,
		OwnerUserID:    921,
		RegionID:       50,
		ParentBDUserID: 921,
		Name:           "Seller Agency",
		Status:         hostdomain.AgencyStatusActive,
		JoinEnabled:    true,
		CreatedAtMs:    100,
		UpdatedAtMs:    100,
	})
	svc := newHostService(repository, 7000, 8000)

	profile, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
		CommandID:    "admin-create-coin-seller-921",
		AdminUserID:  1,
		TargetUserID: 921,
		Reason:       "seed coin seller",
		RequestID:    "req-coin-seller-1",
	})
	if err != nil {
		t.Fatalf("CreateCoinSeller failed: %v", err)
	}
	if profile.Status != hostdomain.CoinSellerStatusActive || profile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
		t.Fatalf("coin seller profile mismatch: %+v", profile)
	}
	retried, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
		CommandID:    "admin-create-coin-seller-921",
		AdminUserID:  1,
		TargetUserID: 921,
	})
	if err != nil {
		t.Fatalf("retry CreateCoinSeller failed: %v", err)
	}
	if retried.UserID != profile.UserID {
		t.Fatalf("coin seller retry must replay existing result: %+v", retried)
	}

	disabled, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
		CommandID:    "admin-disable-coin-seller-921",
		AdminUserID:  1,
		TargetUserID: 921,
		Status:       hostdomain.CoinSellerStatusDisabled,
		Reason:       "pause seller",
	})
	if err != nil {
		t.Fatalf("SetCoinSellerStatus failed: %v", err)
	}
	if disabled.Status != hostdomain.CoinSellerStatusDisabled {
		t.Fatalf("disabled coin seller mismatch: %+v", disabled)
	}
}

func TestListActiveCoinSellersInMyRegionFiltersByCurrentUserRegion(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	repository.PutCountry(userdomain.Country{
		CountryID:          86,
		CountryCode:        "CN",
		CountryName:        "China",
		CountryDisplayName: "中国",
		Enabled:            true,
	})
	repository.PutRegion(userdomain.Region{
		RegionID:   70,
		RegionCode: "east-asia",
		Name:       "East Asia",
		Status:     userdomain.RegionStatusActive,
		Countries:  []string{"CN"},
	})
	repository.PutRegion(userdomain.Region{
		RegionID:   71,
		RegionCode: "west-asia",
		Name:       "West Asia",
		Status:     userdomain.RegionStatusActive,
		Countries:  []string{"CN"},
	})
	seedActiveUser(t, repository, 940, 70)
	repository.PutUser(userdomain.User{
		UserID:               941,
		DefaultDisplayUserID: displayID(941),
		CurrentDisplayUserID: displayID(941),
		Username:             "Seller One",
		Avatar:               "https://cdn.example/seller-one.png",
		Country:              "CN",
		RegionID:             70,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
	repository.PutUser(userdomain.User{
		UserID:               942,
		DefaultDisplayUserID: displayID(942),
		CurrentDisplayUserID: displayID(942),
		Username:             "Disabled Seller",
		Country:              "CN",
		RegionID:             70,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
	repository.PutUser(userdomain.User{
		UserID:               943,
		DefaultDisplayUserID: displayID(943),
		CurrentDisplayUserID: displayID(943),
		Username:             "Other Region Seller",
		Country:              "CN",
		RegionID:             71,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 1,
		OnboardingStatus:     userdomain.OnboardingStatusCompleted,
		Status:               userdomain.StatusActive,
	})
	svc := newHostService(repository, 9000, 10000)
	for _, userID := range []int64{941, 942, 943} {
		if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
			CommandID:    fmt.Sprintf("admin-create-coin-seller-%d", userID),
			AdminUserID:  1,
			TargetUserID: userID,
		}); err != nil {
			t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err)
		}
	}
	if _, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
		CommandID:    "admin-disable-coin-seller-942",
		AdminUserID:  1,
		TargetUserID: 942,
		Status:       hostdomain.CoinSellerStatusDisabled,
	}); err != nil {
		t.Fatalf("SetCoinSellerStatus failed: %v", err)
	}

	items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940)
	if err != nil {
		t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err)
	}
	if len(items) != 1 {
		t.Fatalf("only same-region active sellers should be returned: %+v", items)
	}
	item := items[0]
	if item.UserID != 941 || item.Username != "Seller One" || item.Avatar == "" || item.CountryID != 86 || item.CountryCode != "CN" || item.RegionID != 70 || item.RegionCode != "east-asia" || item.Status != hostdomain.CoinSellerStatusActive {
		t.Fatalf("coin seller item mismatch: %+v", item)
	}
}

func TestHuwaaCoinSellerListAndSubApplicationRemainRegionScoped(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), "huwaa")
	repository := mysqltest.NewRepository(t)
	for _, region := range []userdomain.Region{
		{AppCode: "huwaa", RegionID: 70, RegionCode: "R70", Name: "Region 70", Status: userdomain.RegionStatusActive},
		{AppCode: "huwaa", RegionID: 71, RegionCode: "R71", Name: "Region 71", Status: userdomain.RegionStatusActive},
	} {
		repository.PutRegion(region)
	}
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 940, 70, "US", "")
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 941, 70, "US", "")
	seedActiveUserInAppAndCountry(t, repository, "huwaa", 942, 71, "CA", "")
	svc := newHostService(repository, 23_000, 24_000)
	for _, userID := range []int64{941, 942} {
		if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
			CommandID:    fmt.Sprintf("huwaa-create-coin-seller-%d", userID),
			AdminUserID:  1,
			TargetUserID: userID,
		}); err != nil {
			t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err)
		}
	}

	items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940)
	if err != nil {
		t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err)
	}
	if len(items) != 1 || items[0].UserID != 941 {
		t.Fatalf("Huwaa coin-seller list must remain same-region: %+v", items)
	}
	if _, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "huwaa-submit-cross-region-sub-seller",
		ParentUserID: 941,
		TargetUserID: 942,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("Huwaa sub coin-seller invitation must remain region-scoped: %v", err)
	}
}

func TestCreateSubCoinSellerRPCRequiresAdminReview(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 950, 80)
	seedActiveUser(t, repository, 951, 80)
	svc := newHostService(repository, 10000, 11000)

	_, err := svc.CreateSubCoinSeller(ctx, hostservice.CreateSubCoinSellerInput{
		CommandID:    "legacy-create-sub-seller",
		ParentUserID: 950,
		ChildUserID:  951,
	})
	if got := xerr.CodeOf(err); got != xerr.PermissionDenied {
		t.Fatalf("legacy CreateSubCoinSeller must be blocked by review flow: got %s err=%v", got, err)
	}
}

func TestSubCoinSellerApplicationReviewCreatesActiveRelation(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	repository.PutRegion(userdomain.Region{
		RegionID:   80,
		RegionCode: "r80",
		Name:       "Region 80",
		Status:     userdomain.RegionStatusActive,
		Countries:  []string{"CN"},
	})
	for _, userID := range []int64{950, 951, 952, 953} {
		seedActiveUser(t, repository, userID, 80)
	}
	svc := newHostService(repository, 10000, 11000)
	for _, userID := range []int64{950, 952} {
		if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
			CommandID:    fmt.Sprintf("admin-create-parent-seller-%d", userID),
			AdminUserID:  1,
			TargetUserID: userID,
		}); err != nil {
			t.Fatalf("CreateCoinSeller parent %d failed: %v", userID, err)
		}
	}

	submitted, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "submit-sub-seller-951",
		ParentUserID: 950,
		TargetUserID: 951,
	})
	if err != nil {
		t.Fatalf("SubmitSubCoinSellerApplication failed: %v", err)
	}
	if submitted.Application.Status != hostdomain.CoinSellerSubApplicationStatusPending ||
		submitted.Application.ParentUserID != 950 ||
		submitted.Application.TargetUserID != 951 {
		t.Fatalf("pending application mismatch: %+v", submitted.Application)
	}

	retriedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "submit-sub-seller-951",
		ParentUserID: 950,
		TargetUserID: 951,
	})
	if err != nil {
		t.Fatalf("retry SubmitSubCoinSellerApplication failed: %v", err)
	}
	if retriedSubmit.Application.ApplicationID != submitted.Application.ApplicationID {
		t.Fatalf("submit retry must replay same application: got %d want %d", retriedSubmit.Application.ApplicationID, submitted.Application.ApplicationID)
	}

	duplicatePending, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "submit-sub-seller-951-again",
		ParentUserID: 950,
		TargetUserID: 951,
	})
	if err != nil {
		t.Fatalf("same parent pending submit should replay existing pending: %v", err)
	}
	if duplicatePending.Application.ApplicationID != submitted.Application.ApplicationID {
		t.Fatalf("same parent pending submit must return existing application: got %d want %d", duplicatePending.Application.ApplicationID, submitted.Application.ApplicationID)
	}

	_, err = svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "submit-sub-seller-951-other-parent",
		ParentUserID: 952,
		TargetUserID: 951,
	})
	if got := xerr.CodeOf(err); got != xerr.Conflict {
		t.Fatalf("different parent must not steal pending target: got %s err=%v", got, err)
	}

	beforeApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
		ParentUserID: 950,
		Page:         1,
		PageSize:     20,
	})
	if err != nil {
		t.Fatalf("ListSubCoinSellers before approve failed: %v", err)
	}
	if beforeApprove.Total != 0 || len(beforeApprove.Items) != 0 {
		t.Fatalf("pending application must not appear as active child: %+v", beforeApprove)
	}

	approved, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
		CommandID:     "approve-sub-seller-951",
		AdminUserID:   7,
		ApplicationID: submitted.Application.ApplicationID,
		Decision:      hostdomain.CoinSellerSubApplicationStatusApproved,
		Reason:        "same region",
	})
	if err != nil {
		t.Fatalf("approve sub seller application failed: %v", err)
	}
	if approved.Application.Status != hostdomain.CoinSellerSubApplicationStatusApproved ||
		approved.Relation.ParentUserID != 950 ||
		approved.Relation.ChildUserID != 951 ||
		approved.Child.UserID != 951 {
		t.Fatalf("approved facts mismatch: %+v", approved)
	}

	retriedApprove, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
		CommandID:     "approve-sub-seller-951",
		AdminUserID:   7,
		ApplicationID: submitted.Application.ApplicationID,
		Decision:      hostdomain.CoinSellerSubApplicationStatusApproved,
	})
	if err != nil {
		t.Fatalf("retry approve sub seller application failed: %v", err)
	}
	if retriedApprove.Relation.RelationID != approved.Relation.RelationID {
		t.Fatalf("approve retry must replay same relation: got %d want %d", retriedApprove.Relation.RelationID, approved.Relation.RelationID)
	}

	afterApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
		ParentUserID: 950,
		Page:         1,
		PageSize:     20,
	})
	if err != nil {
		t.Fatalf("ListSubCoinSellers after approve failed: %v", err)
	}
	if afterApprove.Total != 1 || len(afterApprove.Items) != 1 || afterApprove.Items[0].UserID != 951 {
		t.Fatalf("approved child must appear in active list: %+v", afterApprove)
	}

	rejectedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
		CommandID:    "submit-sub-seller-953",
		ParentUserID: 950,
		TargetUserID: 953,
	})
	if err != nil {
		t.Fatalf("SubmitSubCoinSellerApplication for rejected case failed: %v", err)
	}
	rejected, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
		CommandID:     "reject-sub-seller-953",
		AdminUserID:   7,
		ApplicationID: rejectedSubmit.Application.ApplicationID,
		Decision:      hostdomain.CoinSellerSubApplicationStatusRejected,
		Reason:        "manual reject",
	})
	if err != nil {
		t.Fatalf("reject sub seller application failed: %v", err)
	}
	if rejected.Application.Status != hostdomain.CoinSellerSubApplicationStatusRejected || rejected.Relation.RelationID != 0 || rejected.Child.UserID != 0 {
		t.Fatalf("rejected application must not create relation or child: %+v", rejected)
	}
}

func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedActiveUser(t, repository, 931, 60)
	seedActiveUser(t, repository, 932, 60)
	repository.PutHostProfile(hostdomain.HostProfile{
		UserID:          931,
		Status:          hostdomain.HostStatusActive,
		RegionID:        60,
		CurrentAgencyID: 933,
		Source:          hostdomain.HostSourceAdminCreateAgency,
	})
	repository.PutAgency(hostdomain.Agency{
		AgencyID:       933,
		OwnerUserID:    931,
		RegionID:       60,
		ParentBDUserID: 931,
		Status:         hostdomain.AgencyStatusActive,
		JoinEnabled:    true,
	})
	repository.PutBDProfile(hostdomain.BDProfile{
		UserID:   931,
		Role:     hostdomain.BDRoleLeader,
		RegionID: 60,
		Status:   hostdomain.BDStatusActive,
	})
	repository.PutManagerProfile(hostdomain.ManagerProfile{
		UserID: 931,
		Status: hostdomain.ManagerStatusActive,
	})
	svc := newHostService(repository, 8000, 9000)
	if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
		CommandID:    "admin-create-coin-seller-931",
		AdminUserID:  1,
		TargetUserID: 931,
		Reason:       "summary test",
	}); err != nil {
		t.Fatalf("CreateCoinSeller failed: %v", err)
	}
	invite932, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-932",
		InviterUserID: 931,
		TargetUserID:  932,
	})
	if err != nil {
		t.Fatalf("InviteBD failed: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-bd-932",
		ActorUserID:  932,
		InvitationID: invite932.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("accept InviteBD for summary failed: %v", err)
	}

	summary, err := svc.GetUserRoleSummary(ctx, 931)
	if err != nil {
		t.Fatalf("GetUserRoleSummary failed: %v", err)
	}
	if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
		t.Fatalf("leader-only role summary mismatch: %+v", summary)
	}
	selfInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
		CommandID:     "invite-bd-self-931",
		InviterUserID: 931,
		TargetUserID:  931,
	})
	if err != nil {
		t.Fatalf("self InviteBD for summary failed: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "accept-bd-self-931-summary",
		ActorUserID:  931,
		InvitationID: selfInvite.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("accept self InviteBD for summary failed: %v", err)
	}
	combinedSummary, err := svc.GetUserRoleSummary(ctx, 931)
	if err != nil {
		t.Fatalf("GetUserRoleSummary combined failed: %v", err)
	}
	if !combinedSummary.IsBD || !combinedSummary.IsBDLeader || combinedSummary.BDID != 931 {
		t.Fatalf("bd plus leader role summary mismatch: %+v", combinedSummary)
	}
	directBD, err := svc.GetUserRoleSummary(ctx, 932)
	if err != nil {
		t.Fatalf("GetUserRoleSummary direct bd failed: %v", err)
	}
	if directBD.PendingRoleInvitations != 0 || directBD.IsHost || !directBD.IsBD || directBD.BDID != 932 {
		t.Fatalf("direct bd role summary mismatch: %+v", directBD)
	}
}

func TestExternalTeamInvitationFlowKeepsOwnerScopeAndReturnsShortIDs(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	for _, userID := range []int64{1101, 1102, 1103, 1104, 1105} {
		seedActiveUser(t, repository, userID, 91)
	}
	seedActiveUser(t, repository, 1199, 92)
	svc := newHostService(repository, 30_000, 40_000)

	bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
		CommandID:        "external-invite-bd-1102",
		OperatorUserID:   1101,
		TargetUserID:     1102,
		ExpectedRegionID: 91,
	})
	if err != nil {
		t.Fatalf("ExternalInviteBD failed: %v", err)
	}
	if bdInvitation.InvitationType != hostdomain.InvitationTypeExternalBD ||
		bdInvitation.ExternalOperatorUserID != 1101 ||
		bdInvitation.ParentOwnerUserID != 1101 ||
		bdInvitation.ParentLeaderUserID != 0 {
		t.Fatalf("external BD invitation must stay outside the App leader invitation chain: %+v", bdInvitation)
	}
	assertRoleInvitationCreatedOutbox(t, repository, bdInvitation)
	bdInbox, err := svc.ListRoleInvitations(ctx, hostservice.ListRoleInvitationsCommand{
		TargetUserID: 1102,
		Status:       hostdomain.InvitationStatusPending,
		PageSize:     20,
	})
	if err != nil {
		t.Fatalf("ListRoleInvitations for external BD failed: %v", err)
	}
	if len(bdInbox) != 1 || bdInbox[0].InvitationID != bdInvitation.InvitationID {
		t.Fatalf("external BD must remain pending and visible before App confirmation: %+v", bdInbox)
	}
	var bdRowsBeforeAccept int
	if err := repository.RawDB().QueryRow(`
		SELECT COUNT(*) FROM bd_profiles
		WHERE app_code = ? AND user_id = ? AND status = 'active'`,
		appcode.Default, 1102,
	).Scan(&bdRowsBeforeAccept); err != nil {
		t.Fatalf("count external BD before accept failed: %v", err)
	}
	if bdRowsBeforeAccept != 0 {
		t.Fatalf("external BD relationship must not exist before target confirmation: count=%d", bdRowsBeforeAccept)
	}
	bdResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-bd-1102",
		ActorUserID:  1102,
		InvitationID: bdInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	})
	if err != nil {
		t.Fatalf("accept external BD failed: %v", err)
	}
	if bdResult.BDProfile.ParentOwnerUserID != 1101 || bdResult.BDProfile.ParentLeaderUserID != 0 {
		t.Fatalf("external BD must attach directly to operator owner: %+v", bdResult.BDProfile)
	}

	agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
		CommandID:         "external-invite-agency-1103",
		OperatorUserID:    1101,
		ParentBDUserID:    1102,
		TargetUserID:      1103,
		AgencyName:        "External Team Agency",
		ScopeOwnerUserIDs: []int64{1101},
		ExpectedRegionID:  91,
	})
	if err != nil {
		t.Fatalf("ExternalInviteAgency failed: %v", err)
	}
	if agencyInvitation.InvitationType != hostdomain.InvitationTypeExternalAgency ||
		agencyInvitation.ExternalOperatorUserID != 1101 ||
		agencyInvitation.ParentOwnerUserID != 1101 ||
		agencyInvitation.ParentBDUserID != 1102 {
		t.Fatalf("external Agency invitation must persist the selected external parent chain: %+v", agencyInvitation)
	}
	assertRoleInvitationCreatedOutbox(t, repository, agencyInvitation)
	var agencyRowsBeforeAccept int
	if err := repository.RawDB().QueryRow(`
		SELECT COUNT(*) FROM agencies
		WHERE app_code = ? AND owner_user_id = ? AND status = 'active'`,
		appcode.Default, 1103,
	).Scan(&agencyRowsBeforeAccept); err != nil {
		t.Fatalf("count external Agency before accept failed: %v", err)
	}
	if agencyRowsBeforeAccept != 0 {
		t.Fatalf("external Agency relationship must not exist before target confirmation: count=%d", agencyRowsBeforeAccept)
	}
	agencyResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-agency-1103",
		ActorUserID:  1103,
		InvitationID: agencyInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	})
	if err != nil {
		t.Fatalf("accept external Agency failed: %v", err)
	}
	if agencyResult.Agency.ParentBDUserID != 1102 || agencyResult.Agency.OwnerUserID != 1103 {
		t.Fatalf("external Agency parent mismatch: %+v", agencyResult.Agency)
	}

	hostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
		CommandID:         "external-invite-host-1104",
		OperatorUserID:    1101,
		AgencyID:          agencyResult.Agency.AgencyID,
		TargetUserID:      1104,
		ScopeOwnerUserIDs: []int64{1101},
		ExpectedRegionID:  91,
	})
	if err != nil {
		t.Fatalf("ExternalInviteHost failed: %v", err)
	}
	if hostInvitation.InvitationType != hostdomain.InvitationTypeExternalHost ||
		hostInvitation.ExternalOperatorUserID != 1101 ||
		hostInvitation.ParentAgencyID != agencyResult.Agency.AgencyID {
		t.Fatalf("external Host invitation must persist the selected Agency independently: %+v", hostInvitation)
	}
	assertRoleInvitationCreatedOutbox(t, repository, hostInvitation)
	var hostRowsBeforeAccept int
	if err := repository.RawDB().QueryRow(`
		SELECT COUNT(*) FROM agency_memberships
		WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
		appcode.Default, 1104,
	).Scan(&hostRowsBeforeAccept); err != nil {
		t.Fatalf("count external Host before accept failed: %v", err)
	}
	if hostRowsBeforeAccept != 0 {
		t.Fatalf("external Host relationship must not exist before target confirmation: count=%d", hostRowsBeforeAccept)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-host-1104",
		ActorUserID:  1104,
		InvitationID: hostInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("accept external Host failed: %v", err)
	}

	// 拒绝分支走同一个 App 确认状态机，但绝不能落 Host 关系；目标用户
	// 仍能从 C2C 消息点击拒绝，并让邀请进入可审计终态。
	rejectedHostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
		CommandID:         "external-invite-host-1105",
		OperatorUserID:    1101,
		AgencyID:          agencyResult.Agency.AgencyID,
		TargetUserID:      1105,
		ScopeOwnerUserIDs: []int64{1101},
		ExpectedRegionID:  91,
	})
	if err != nil {
		t.Fatalf("ExternalInviteHost for reject failed: %v", err)
	}
	assertRoleInvitationCreatedOutbox(t, repository, rejectedHostInvitation)
	rejectedHostResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-reject-host-1105",
		ActorUserID:  1105,
		InvitationID: rejectedHostInvitation.InvitationID,
		Action:       hostdomain.InvitationActionReject,
		Reason:       "not available",
	})
	if err != nil {
		t.Fatalf("reject external Host failed: %v", err)
	}
	if rejectedHostResult.Invitation.Status != hostdomain.InvitationStatusRejected ||
		rejectedHostResult.Invitation.ProcessedByUserID != 1105 {
		t.Fatalf("external Host reject terminal state mismatch: %+v", rejectedHostResult.Invitation)
	}
	var rejectedHostMemberships int
	if err := repository.RawDB().QueryRow(`
		SELECT COUNT(*) FROM agency_memberships
		WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
		appcode.Default, 1105,
	).Scan(&rejectedHostMemberships); err != nil {
		t.Fatalf("count rejected external Host membership failed: %v", err)
	}
	if rejectedHostMemberships != 0 {
		t.Fatalf("rejected external Host invitation must not create membership: count=%d", rejectedHostMemberships)
	}

	team, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
		ScopeOwnerUserIDs: []int64{1101},
		RegionID:          91,
		Page:              1,
		PageSize:          20,
	})
	if err != nil {
		t.Fatalf("ListExternalTeamUsers failed: %v", err)
	}
	if team.Total != 3 || len(team.Users) != 3 {
		t.Fatalf("external team should contain BD, Agency owner and Host only: %+v", team)
	}
	rolesByUser := make(map[int64][]string, len(team.Users))
	for _, user := range team.Users {
		if user.CurrentDisplayUserID == "" || user.DefaultDisplayUserID == "" || user.RegionID != 91 {
			t.Fatalf("external team projection must include both short IDs and canonical region: %+v", user)
		}
		rolesByUser[user.UserID] = user.Roles
	}
	if fmt.Sprint(rolesByUser[1102]) != "[bd]" || fmt.Sprint(rolesByUser[1103]) != "[agency]" || fmt.Sprint(rolesByUser[1104]) != "[host]" {
		t.Fatalf("unexpected external team roles: %+v", rolesByUser)
	}
	if _, err := repository.RawDB().Exec(`
		UPDATE users SET status = 'banned'
		WHERE app_code = ? AND user_id = ?`, appcode.Default, 1102); err != nil {
		t.Fatalf("ban external BD fixture failed: %v", err)
	}
	activeAfterBDBan, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
		ScopeOwnerUserIDs: []int64{1101},
		RegionID:          91,
		Status:            "active",
		Page:              1,
		PageSize:          20,
	})
	if err != nil {
		t.Fatalf("ListExternalTeamUsers after BD ban failed: %v", err)
	}
	if activeAfterBDBan.Total != 2 || len(activeAfterBDBan.Users) != 2 {
		t.Fatalf("banned BD must not cut its active Agency/Host descendants: %+v", activeAfterBDBan)
	}
	bannedTeam, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
		ScopeOwnerUserIDs: []int64{1101},
		RegionID:          91,
		Status:            "banned",
		Page:              1,
		PageSize:          20,
	})
	if err != nil {
		t.Fatalf("ListExternalTeamUsers banned target lookup failed: %v", err)
	}
	if bannedTeam.Total != 1 || len(bannedTeam.Users) != 1 || bannedTeam.Users[0].UserID != 1102 {
		t.Fatalf("original external owner must retain scope over banned BD: %+v", bannedTeam)
	}

	candidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
		OperatorUserID:   1101,
		ExpectedRegionID: 91,
		InvitationType:   hostdomain.InvitationTypeHost,
		Keyword:          displayID(1105),
	})
	if err != nil || candidate.UserID != 1105 || candidate.CurrentDisplayUserID != displayID(1105) {
		t.Fatalf("same-region exact candidate lookup failed: candidate=%+v err=%v", candidate, err)
	}
	// active lease 是靓号当前归属事实：即使 users.current_display_user_id 快照尚未同步，
	// 外管邀请也必须能精确命中，但不能因此开放区域用户模糊浏览。
	if _, err := repository.RawDB().Exec(`
		INSERT INTO pretty_display_user_id_leases (
			app_code, lease_id, display_user_id, user_id, status,
			starts_at_ms, expires_at_ms, source, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 'active', ?, 0, 'admin_grant', ?, ?)`,
		appcode.Default, "external-candidate-pretty-1105", "pretty-1105", int64(1105),
		int64(1), int64(1), int64(1),
	); err != nil {
		t.Fatalf("seed active pretty lease failed: %v", err)
	}
	prettyCandidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
		OperatorUserID:   1101,
		ExpectedRegionID: 91,
		InvitationType:   hostdomain.InvitationTypeHost,
		Keyword:          "pretty-1105",
	})
	if err != nil || prettyCandidate.UserID != 1105 {
		t.Fatalf("active pretty lease exact candidate lookup failed: candidate=%+v err=%v", prettyCandidate, err)
	}
	if _, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
		OperatorUserID:   1101,
		ExpectedRegionID: 91,
		InvitationType:   hostdomain.InvitationTypeHost,
		Keyword:          displayID(1199),
	}); xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("cross-region candidate must be hidden as not found: %v", err)
	}
}

func TestExternalInvitationAcceptFailsAfterRegionOrParentOwnershipDrift(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	for _, userID := range []int64{1201, 1202, 1203, 1204} {
		seedActiveUser(t, repository, userID, 93)
	}
	svc := newHostService(repository, 31_000, 41_000)

	bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
		CommandID:        "external-invite-bd-region-drift",
		OperatorUserID:   1201,
		TargetUserID:     1202,
		ExpectedRegionID: 93,
	})
	if err != nil {
		t.Fatalf("ExternalInviteBD setup failed: %v", err)
	}
	seedActiveUser(t, repository, 1202, 94)
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-bd-region-drift",
		ActorUserID:  1202,
		InvitationID: bdInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); xerr.CodeOf(err) != xerr.RegionMismatch {
		t.Fatalf("region drift must block external BD acceptance: %v", err)
	}

	bdInvitation, err = svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
		CommandID:        "external-invite-bd-parent-drift",
		OperatorUserID:   1201,
		TargetUserID:     1203,
		ExpectedRegionID: 93,
	})
	if err != nil {
		t.Fatalf("ExternalInviteBD parent drift setup failed: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-bd-parent-drift",
		ActorUserID:  1203,
		InvitationID: bdInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); err != nil {
		t.Fatalf("accept BD parent drift setup failed: %v", err)
	}
	agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
		CommandID:         "external-invite-agency-parent-drift",
		OperatorUserID:    1201,
		ParentBDUserID:    1203,
		TargetUserID:      1204,
		ScopeOwnerUserIDs: []int64{1201},
		ExpectedRegionID:  93,
	})
	if err != nil {
		t.Fatalf("ExternalInviteAgency parent drift setup failed: %v", err)
	}
	if _, err := repository.RawDB().Exec(`UPDATE bd_profiles SET parent_owner_user_id = 1202 WHERE user_id = 1203`); err != nil {
		t.Fatalf("drift parent owner failed: %v", err)
	}
	if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
		CommandID:    "external-accept-agency-parent-drift",
		ActorUserID:  1204,
		InvitationID: agencyInvitation.InvitationID,
		Action:       hostdomain.InvitationActionAccept,
	}); xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("parent ownership drift must block external Agency acceptance: %v", err)
	}
}
