package userbehavior

import (
	"context"
	"errors"
	"time"

	"github.com/go-logr/logr"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	account "github.com/tidbcloud/account/idl/pbgen/proto/account"
	"github.com/tidbcloud/devops/entity/event"
	tntent "github.com/tidbcloud/devops/entity/tenant"
	ubent "github.com/tidbcloud/devops/entity/userbehavior"
	ubmod "github.com/tidbcloud/devops/models/userbehavior"
	"github.com/tidbcloud/devops/pkg/infra/clients/accountcli"
	"github.com/tidbcloud/devops/pkg/infra/clients/sendgridcli"
	"github.com/tidbcloud/devops/pkg/infra/config"
	tenantsvc "github.com/tidbcloud/devops/pkg/service/tenant"
	"github.com/tidbcloud/devops/pkg/service/userbehavior/gtm"
	"github.com/tidbcloud/devops/pkg/service/userbehavior/repo"
	"github.com/tidbcloud/pkgv2/db"
	"go.uber.org/mock/gomock"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type fakeSendGridClient struct {
	sendPlainCalls int
	sendPlainErr   error
	messageID      string
	lastPlainReq   *sendgridcli.SendPlainEmailRequest
}

func (f *fakeSendGridClient) SendBulkPersonalizedEmails(ctx context.Context, req sendgridcli.SendBulkPersonalizedEmailsRequest) error {
	return nil
}

func (f *fakeSendGridClient) SendPersonalizedEmail(ctx context.Context, req sendgridcli.SendPersonalizedEmailRequest) error {
	return nil
}

func (f *fakeSendGridClient) SendPlainEmail(ctx context.Context, req sendgridcli.SendPlainEmailRequest) (*sendgridcli.SendPlainEmailResponse, error) {
	f.sendPlainCalls++
	copied := req
	f.lastPlainReq = &copied
	if f.sendPlainErr != nil {
		return nil, f.sendPlainErr
	}
	return &sendgridcli.SendPlainEmailResponse{MessageID: f.messageID, StatusCode: 202}, nil
}

func newUserBehaviorTestDB() (*gorm.DB, repo.UserBehaviorRepo) {
	gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
	Expect(err).ToNot(HaveOccurred())
	sqlDB, err := gormDB.DB()
	Expect(err).ToNot(HaveOccurred())
	sqlDB.SetMaxOpenConns(1)
	Expect(gormDB.AutoMigrate(&ubmod.GTMLead{})).To(Succeed())
	Expect(gormDB.AutoMigrate(&ubmod.GTMSentEmail{})).To(Succeed())
	Expect(gormDB.AutoMigrate(&ubmod.GTMEmailSuppression{})).To(Succeed())

	provider := db.NewProvider(gormDB)
	return gormDB, repo.NewUBRepo(provider)
}

var _ = Describe("user behavior service", func() {
	fixedUTCMinusSeven := func() *time.Location {
		return gtmEmailScheduleLocation()
	}

	Describe("isEducationalTriage", func() {
		It("classifies educational examples correctly", func() {
			tests := []struct {
				name   string
				triage string
				want   bool
			}{
				{
					name: "GMI non-educational should pass",
					triage: `- Company Name: GMI Cloud
- Headquarter: Mountain View, CA, United States
- Vertical: AI-native GPU Cloud and Machine Learning Infrastructure
- Business Model: Vertically Integrated GPU Cloud Service (IaaS)
- Approximate Annual Revenue: Unknown
- Funding Series: Series A`,
					want: false,
				},
				{
					name: "stu.sandi.net educational should be filtered",
					triage: `- Company Name: San Diego Unified School District
- Headquarter: San Diego, California
- Vertical: K‑12 Education
- Business Model: Public School District
- Approximate Annual Revenue: $2.54 Billion
- Funding Series: Not Applicable`,
					want: true,
				},
				{
					name: "opsd educational with unicode separators should be filtered",
					triage: "- Company Name: Ogallala Public\u00a0Schools\n" +
						"- Headquarter: Ogallala, Nebraska\n" +
						"- Vertical: K‑12 Education\n" +
						"- Business Model: Public School\u00a0District\n" +
						"- Approximate Annual Revenue: $14.9 Million\n" +
						"- Funding Series: Not applicable",
					want: true,
				},
			}

			for _, tc := range tests {
				tc := tc
				By(tc.name)
				Expect(isEducationalTriage(tc.triage)).To(Equal(tc.want))
			}
		})
	})

	Describe("PreprocessUBEvent startup region", func() {
		newStartupRegionTestService := func(ctrl *gomock.Controller) (*svc, *accountcli.MockClient, *tenantsvc.MockTenantServiceAPI) {
			accountCli := accountcli.NewMockClient(ctrl)
			tenantSvc := tenantsvc.NewMockTenantServiceAPI(ctrl)
			return &svc{
				accountCli:    accountCli,
				tenantSvc:     tenantSvc,
				gtmEnricher:   gtm.NewEnricher(nil),
				handledTenant: map[uint64]struct{}{},
				logger:        logr.Discard(),
			}, accountCli, tenantSvc
		}

		It("prefers Salesforce region when available", func() {
			ctrl := gomock.NewController(GinkgoT())
			service, accountCli, tenantSvc := newStartupRegionTestService(ctrl)
			ev := &startupUBEvent{TenantEvent: event.TenantEvent{OrgId: 123, OrgEventType: event.OrgEventTypeActivate}}

			accountCli.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(&account.Org{
				Id:      123,
				Name:    "Acme",
				Email:   "founder@acme.io",
				Country: "US",
				Kind:    string(tntent.TenantKindExternal),
			}, nil)
			tenantSvc.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(&tntent.Tenant{
				Id:               123,
				SalesForceRegion: tntent.TenantRegionEMEA,
			}, nil)

			info, err := service.PreprocessUBEvent(context.Background(), ev)
			Expect(err).ToNot(HaveOccurred())
			Expect(info.bizRegion).To(Equal("EMEA"))
			Expect(ev.bizRegion).ToNot(BeNil())
			Expect(*ev.bizRegion).To(Equal("EMEA"))
		})

		It("falls back to account country when Salesforce region is unknown", func() {
			ctrl := gomock.NewController(GinkgoT())
			service, accountCli, tenantSvc := newStartupRegionTestService(ctrl)
			ev := &startupUBEvent{TenantEvent: event.TenantEvent{OrgId: 123, OrgEventType: event.OrgEventTypeActivate}}

			accountCli.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(&account.Org{
				Id:      123,
				Name:    "Acme",
				Email:   "founder@acme.io",
				Country: "US",
				Kind:    string(tntent.TenantKindExternal),
			}, nil)
			tenantSvc.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(&tntent.Tenant{
				Id:               123,
				SalesForceRegion: tntent.TenantRegionUnknown,
			}, nil)

			info, err := service.PreprocessUBEvent(context.Background(), ev)
			Expect(err).ToNot(HaveOccurred())
			Expect(info.bizRegion).To(Equal("NA"))
			Expect(ev.bizRegion).ToNot(BeNil())
			Expect(*ev.bizRegion).To(Equal("NA"))
		})

		It("falls back to account country when tenant lookup fails", func() {
			ctrl := gomock.NewController(GinkgoT())
			service, accountCli, tenantSvc := newStartupRegionTestService(ctrl)
			ev := &startupUBEvent{TenantEvent: event.TenantEvent{OrgId: 123, OrgEventType: event.OrgEventTypeActivate}}

			accountCli.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(&account.Org{
				Id:      123,
				Name:    "Acme",
				Email:   "founder@acme.io",
				Country: "US",
				Kind:    string(tntent.TenantKindExternal),
			}, nil)
			tenantSvc.EXPECT().GetTenant(gomock.Any(), uint64(123)).Return(nil, errors.New("not found"))

			info, err := service.PreprocessUBEvent(context.Background(), ev)
			Expect(err).ToNot(HaveOccurred())
			Expect(info.bizRegion).To(Equal("NA"))
			Expect(ev.bizRegion).ToNot(BeNil())
			Expect(*ev.bizRegion).To(Equal("NA"))
		})
	})

	Describe("shouldSkipGTMEmailForGeo", func() {
		It("disables email only for configured geographies", func() {
			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						EnabledGeoSelectors:       []string{"NA", "EMEA", "LATAM"},
						EmailDisabledGeoSelectors: []string{"NA"},
					},
				},
			}

			disabled, matchType := service.shouldSkipGTMEmailForGeo("NA", "US")
			Expect(disabled).To(BeTrue())
			Expect(matchType).To(Equal("region"))

			disabled, matchType = service.shouldSkipGTMEmailForGeo("EMEA", "DE")
			Expect(disabled).To(BeFalse())
			Expect(matchType).To(BeEmpty())
		})

		It("keeps email enabled when no disabled geography is configured", func() {
			service := &svc{}

			disabled, matchType := service.shouldSkipGTMEmailForGeo("NA", "US")
			Expect(disabled).To(BeFalse())
			Expect(matchType).To(BeEmpty())
		})
	})

	Describe("queueGTMEmail", func() {
		It("persists a queued record", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()
			base := time.Date(2026, 3, 23, 16, 0, 0, 0, time.UTC)

			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled:                true,
						FromAddress:            "sales@tidbcloud.com",
						EmailDelay:             6 * time.Hour,
						EmailQueueScanInterval: 5 * time.Minute,
						InternalCCList:         []string{"cc1@pingcap.com", "cc2@pingcap.com"},
					},
				},
				repo:       ubRepo,
				logger:     logr.Discard(),
				nowFn:      func() time.Time { return base },
				randIntnFn: func(n int) int { return 0 },
			}

			lead := &ubent.GTMLead{
				ID:       1,
				TenantID: 123,
				Email:    "founder@example.com",
			}
			match := gtm.MatchResult{
				Matched:            true,
				UseCaseProfile:     "ai-llm-platform",
				CustomerReferences: []string{"manus", "dify"},
				Reason:             "test",
			}
			email := &gtm.EmailContent{
				Subject:  "Subject",
				HTMLBody: "<p>Body</p>",
			}

			queued, err := service.queueGTMEmail(context.Background(), lead, match, email)
			Expect(err).ToNot(HaveOccurred())
			Expect(queued).ToNot(BeNil())
			Expect(queued.Status).To(Equal("queued"))
			Expect(queued.SentAt).To(BeNil())
			Expect(queued.ScheduledAt).To(BeTemporally("==", time.Date(2026, 3, 23, 22, 0, 0, 0, time.UTC)))
			Expect(queued.ScheduledAt.Location()).To(Equal(time.UTC))

			var count int64
			Expect(gormDB.Table("gtm_sent_emails").Count(&count).Error).ToNot(HaveOccurred())
			Expect(count).To(Equal(int64(1)))
		})

		It("uses the persisted lead ID when queueing an email", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()
			base := time.Date(2026, 3, 23, 16, 0, 0, 0, time.UTC)

			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled:                true,
						FromName:               "Pipeline Sender",
						FromAddress:            "sales@tidbcloud.com",
						EmailDelay:             6 * time.Hour,
						EmailQueueScanInterval: 5 * time.Minute,
					},
				},
				repo:       ubRepo,
				logger:     logr.Discard(),
				nowFn:      func() time.Time { return base },
				randIntnFn: func(n int) int { return 0 },
			}

			lead := &ubent.GTMLead{
				TenantID:      123,
				Email:         "founder@example.com",
				EmailDomain:   "example.com",
				RawEnrichment: `{"company_name":"Example AI"}`,
			}
			Expect(ubRepo.CreateGTMLead(context.Background(), lead)).To(Succeed())
			Expect(lead.ID).ToNot(BeZero())

			match := gtm.MatchResult{
				Matched:            true,
				UseCaseProfile:     "ai-llm-platform",
				CustomerReferences: []string{"manus", "dify"},
				Reason:             "test",
			}
			email := &gtm.EmailContent{
				Subject:  "Subject",
				HTMLBody: "<p>Body</p>",
			}

			queued, err := service.queueGTMEmail(context.Background(), lead, match, email)
			Expect(err).ToNot(HaveOccurred())
			Expect(queued).ToNot(BeNil())

			var row ubmod.GTMSentEmail
			Expect(gormDB.Model(&ubmod.GTMSentEmail{}).Where("id = ?", queued.ID).First(&row).Error).ToNot(HaveOccurred())
			Expect(row.LeadID).To(Equal(lead.ID))
			Expect(row.LeadID).ToNot(BeZero())
		})

		It("does not queue when the campaign is suppressed", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()

			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled: true,
					},
				},
				repo:   ubRepo,
				logger: logr.Discard(),
			}

			_, err := service.SuppressGTMEmail(context.Background(), SuppressGTMEmailParam{
				TenantID:     123,
				CampaignName: GTMCampaignQualifiedSignupFollowup,
				Source:       "na-gtm-engineering",
				Reason:       "external_sender_claimed",
			})
			Expect(err).ToNot(HaveOccurred())

			queued, err := service.queueGTMEmail(context.Background(), &ubent.GTMLead{
				ID:       1,
				TenantID: 123,
				Email:    "founder@example.com",
			}, gtm.MatchResult{
				Matched:        true,
				UseCaseProfile: "ai-llm-platform",
				Reason:         "test",
			}, &gtm.EmailContent{
				Subject:  "Subject",
				HTMLBody: "<p>Body</p>",
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(queued).To(BeNil())

			var count int64
			Expect(gormDB.Table("gtm_sent_emails").Count(&count).Error).ToNot(HaveOccurred())
			Expect(count).To(Equal(int64(0)))
		})
	})

	Describe("SuppressGTMEmail", func() {
		It("returns status for cancelled, already sending, and future suppression cases", func() {
			_, ubRepo := newUserBehaviorTestDB()
			service := &svc{
				repo:   ubRepo,
				logger: logr.Discard(),
			}
			now := time.Now().UTC()

			Expect(ubRepo.CreateGTMSentEmail(context.Background(), &ubent.GTMSentEmail{
				LeadID:         1,
				TenantID:       123,
				ToAddress:      "queued@example.com",
				Subject:        "Queued",
				Body:           "<p>Body</p>",
				UseCaseProfile: "ai-llm-platform",
				ScheduledAt:    now,
				Status:         "queued",
			})).To(Succeed())

			cancelled, err := service.SuppressGTMEmail(context.Background(), SuppressGTMEmailParam{
				TenantID:     123,
				CampaignName: GTMCampaignQualifiedSignupFollowup,
				Source:       "na-gtm-engineering",
				Reason:       "external_sender_claimed",
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(cancelled.Status).To(Equal(GTMEmailSuppressStatusCancelled))
			Expect(cancelled.CancelledCount).To(Equal(int64(1)))

			Expect(ubRepo.CreateGTMSentEmail(context.Background(), &ubent.GTMSentEmail{
				LeadID:         2,
				TenantID:       456,
				ToAddress:      "sending@example.com",
				Subject:        "Sending",
				Body:           "<p>Body</p>",
				UseCaseProfile: "ai-llm-platform",
				ScheduledAt:    now,
				Status:         "sending",
			})).To(Succeed())

			alreadySending, err := service.SuppressGTMEmail(context.Background(), SuppressGTMEmailParam{
				TenantID:     456,
				CampaignName: GTMCampaignQualifiedSignupFollowup,
				Source:       "na-gtm-engineering",
				Reason:       "external_sender_claimed",
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(alreadySending.Status).To(Equal(GTMEmailSuppressStatusAlreadySentOrSending))
			Expect(alreadySending.CancelledCount).To(Equal(int64(0)))

			notFound, err := service.SuppressGTMEmail(context.Background(), SuppressGTMEmailParam{
				TenantID:     789,
				CampaignName: GTMCampaignQualifiedSignupFollowup,
				Source:       "na-gtm-engineering",
				Reason:       "external_sender_claimed",
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(notFound.Status).To(Equal(GTMEmailSuppressStatusNotFoundButSuppressed))
			Expect(notFound.CancelledCount).To(Equal(int64(0)))
		})
	})

	Describe("computeGTMEmailScheduledAt", func() {
		It("keeps the computed time when it falls inside LA business hours", func() {
			la := fixedUTCMinusSeven()
			base := time.Date(2026, 3, 23, 16, 0, 0, 0, time.UTC)

			scheduledAt, err := computeGTMEmailScheduledAt(base, 6*time.Hour, func(n int) int { return 7 })
			Expect(err).ToNot(HaveOccurred())
			Expect(scheduledAt).To(Equal(time.Date(2026, 3, 23, 15, 0, 0, 0, la)))
		})

		It("defers after-hours sends to the next business day morning with jitter", func() {
			la := fixedUTCMinusSeven()
			base := time.Date(2026, 3, 23, 23, 30, 0, 0, time.UTC)

			scheduledAt, err := computeGTMEmailScheduledAt(base, 2*time.Hour, func(n int) int {
				Expect(n).To(Equal(60))
				return 7
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(scheduledAt).To(Equal(time.Date(2026, 3, 24, 9, 7, 0, 0, la)))
		})

		It("moves weekday before-hours sends to the same day morning with jitter", func() {
			la := fixedUTCMinusSeven()
			base := time.Date(2026, 3, 23, 14, 30, 0, 0, time.UTC)

			scheduledAt, err := computeGTMEmailScheduledAt(base, 1*time.Hour, func(n int) int {
				Expect(n).To(Equal(60))
				return 5
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(scheduledAt).To(Equal(time.Date(2026, 3, 23, 9, 5, 0, 0, la)))
		})

		It("defers weekend sends to the next business day morning with jitter", func() {
			la := fixedUTCMinusSeven()
			base := time.Date(2026, 3, 27, 23, 0, 0, 0, time.UTC)

			scheduledAt, err := computeGTMEmailScheduledAt(base, 20*time.Hour, func(n int) int {
				Expect(n).To(Equal(60))
				return 4
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(scheduledAt).To(Equal(time.Date(2026, 3, 30, 9, 4, 0, 0, la)))
		})

		It("uses the fixed UTC-7 schedule during winter", func() {
			la := fixedUTCMinusSeven()
			base := time.Date(2026, 1, 15, 16, 0, 0, 0, time.UTC)

			scheduledAt, err := computeGTMEmailScheduledAt(base, 0, func(n int) int { return 0 })
			Expect(err).ToNot(HaveOccurred())
			Expect(scheduledAt).To(Equal(time.Date(2026, 1, 15, 9, 0, 0, 0, la)))
		})
	})

	Describe("persistGTMLeadMatch", func() {
		It("stores the matched use case on the lead record", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()

			service := &svc{
				repo:   ubRepo,
				logger: logr.Discard(),
			}

			lead := &ubent.GTMLead{
				TenantID:      123,
				Email:         "founder@example.com",
				EmailDomain:   "example.com",
				RawEnrichment: `{"company_name":"Example AI"}`,
			}
			Expect(ubRepo.CreateGTMLead(context.Background(), lead)).To(Succeed())
			Expect(lead.ID).ToNot(BeZero())

			match := gtm.MatchResult{
				Matched:        true,
				UseCaseProfile: "ai-llm-platform",
				Reason:         "matches profile",
			}

			Expect(service.persistGTMLeadMatch(context.Background(), lead, match)).To(Succeed())
			Expect(lead.MatchedUseCase).ToNot(BeNil())
			Expect(*lead.MatchedUseCase).To(Equal("ai-llm-platform"))
			Expect(lead.UseCaseMatchReason).ToNot(BeNil())
			Expect(*lead.UseCaseMatchReason).To(Equal("matches profile"))

			var row ubmod.GTMLead
			Expect(gormDB.Model(&ubmod.GTMLead{}).Where("id = ?", lead.ID).First(&row).Error).ToNot(HaveOccurred())
			Expect(row.MatchedUseCase).ToNot(BeNil())
			Expect(*row.MatchedUseCase).To(Equal("ai-llm-platform"))
			Expect(row.UseCaseMatchReason).ToNot(BeNil())
			Expect(*row.UseCaseMatchReason).To(Equal("matches profile"))
		})
	})

	Describe("ProcessQueuedGTMEmails", func() {
		It("sends due emails and marks them sent", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()

			fakeSG := &fakeSendGridClient{messageID: "msg-123"}
			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled:                true,
						FromName:               "Pipeline Sender",
						FromAddress:            "sales@tidbcloud.com",
						EmailDelay:             6 * time.Hour,
						EmailQueueScanInterval: 5 * time.Minute,
					},
				},
				repo:        ubRepo,
				sendGridCli: fakeSG,
				logger:      logr.Discard(),
			}

			cc := "cc1@pingcap.com,cc2@pingcap.com"
			Expect(ubRepo.CreateGTMSentEmail(context.Background(), &ubent.GTMSentEmail{
				LeadID:         1,
				TenantID:       123,
				ToAddress:      "founder@example.com",
				Subject:        "Subject",
				Body:           "<p>Body</p>",
				CCAddresses:    &cc,
				UseCaseProfile: "ai-llm-platform",
				ScheduledAt:    time.Now().UTC().Add(-time.Minute),
				Status:         "queued",
			})).To(Succeed())

			Expect(service.ProcessQueuedGTMEmails(context.Background())).To(Succeed())
			Expect(fakeSG.sendPlainCalls).To(Equal(1))
			Expect(fakeSG.lastPlainReq).ToNot(BeNil())
			Expect(fakeSG.lastPlainReq.FromName).To(Equal("Pipeline Sender"))
			Expect(fakeSG.lastPlainReq.To).To(Equal("founder@example.com"))
			Expect(fakeSG.lastPlainReq.CC).To(Equal([]string{"cc1@pingcap.com", "cc2@pingcap.com"}))

			var row ubmod.GTMSentEmail
			Expect(gormDB.Model(&ubmod.GTMSentEmail{}).Where("to_address = ?", "founder@example.com").First(&row).Error).ToNot(HaveOccurred())
			Expect(row.Status).To(Equal("sent"))
			Expect(row.SentAt).ToNot(BeNil())
			Expect(row.MessageID).ToNot(BeNil())
			Expect(*row.MessageID).To(Equal("msg-123"))
			Expect(row.AttemptCount).To(Equal(uint32(1)))
			Expect(row.LastAttemptAt).ToNot(BeNil())
		})

		It("overrides To with mockToAddress only", func() {
			_, ubRepo := newUserBehaviorTestDB()

			fakeSG := &fakeSendGridClient{messageID: "msg-123"}
			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled:                true,
						MockToAddress:          "gtm-sandbox@tidbcloud.com",
						FromAddress:            "sales@tidbcloud.com",
						EmailDelay:             6 * time.Hour,
						EmailQueueScanInterval: 5 * time.Minute,
					},
				},
				repo:        ubRepo,
				sendGridCli: fakeSG,
				logger:      logr.Discard(),
			}

			cc := "cc1@pingcap.com,cc2@pingcap.com"
			Expect(ubRepo.CreateGTMSentEmail(context.Background(), &ubent.GTMSentEmail{
				LeadID:         1,
				TenantID:       123,
				ToAddress:      "founder@example.com",
				Subject:        "Subject",
				Body:           "<p>Body</p>",
				CCAddresses:    &cc,
				UseCaseProfile: "ai-llm-platform",
				ScheduledAt:    time.Now().UTC().Add(-time.Minute),
				Status:         "queued",
			})).To(Succeed())

			Expect(service.ProcessQueuedGTMEmails(context.Background())).To(Succeed())
			Expect(fakeSG.sendPlainCalls).To(Equal(1))
			Expect(fakeSG.lastPlainReq).ToNot(BeNil())
			Expect(fakeSG.lastPlainReq.To).To(Equal("gtm-sandbox@tidbcloud.com"))
			Expect(fakeSG.lastPlainReq.CC).To(Equal([]string{"cc1@pingcap.com", "cc2@pingcap.com"}))
		})

		It("marks send failures as failed", func() {
			gormDB, ubRepo := newUserBehaviorTestDB()

			fakeSG := &fakeSendGridClient{sendPlainErr: errors.New("send failed")}
			service := &svc{
				conf: config.UserBehaviorServiceConfig{
					GTM: config.GTMConfig{
						Enabled:                true,
						FromAddress:            "sales@tidbcloud.com",
						EmailDelay:             6 * time.Hour,
						EmailQueueScanInterval: 5 * time.Minute,
					},
				},
				repo:        ubRepo,
				sendGridCli: fakeSG,
				logger:      logr.Discard(),
			}

			Expect(ubRepo.CreateGTMSentEmail(context.Background(), &ubent.GTMSentEmail{
				LeadID:         1,
				TenantID:       123,
				ToAddress:      "founder@example.com",
				Subject:        "Subject",
				Body:           "<p>Body</p>",
				UseCaseProfile: "ai-llm-platform",
				ScheduledAt:    time.Now().UTC().Add(-time.Minute),
				Status:         "queued",
			})).To(Succeed())

			Expect(service.ProcessQueuedGTMEmails(context.Background())).To(Succeed())
			Expect(fakeSG.sendPlainCalls).To(Equal(1))

			var row ubmod.GTMSentEmail
			Expect(gormDB.Model(&ubmod.GTMSentEmail{}).Where("to_address = ?", "founder@example.com").First(&row).Error).ToNot(HaveOccurred())
			Expect(row.Status).To(Equal("failed"))
			Expect(row.SentAt).To(BeNil())
			Expect(row.ErrorMessage).ToNot(BeNil())
			Expect(*row.ErrorMessage).ToNot(BeEmpty())
			Expect(row.AttemptCount).To(Equal(uint32(1)))
			Expect(row.LastAttemptAt).ToNot(BeNil())
		})
	})

	Describe("resolveGTMContactInfo", func() {
		It("uses the matched tenant user name", func() {
			ctrl := gomock.NewController(GinkgoT())
			accountCli := accountcli.NewMockClient(ctrl)
			service := &svc{accountCli: accountCli, logger: logr.Discard()}

			accountCli.EXPECT().
				GetTenantUsers(gomock.Any(), "123", accountcli.GetUsersReq{
					Search:    "founder@example.com",
					Page:      1,
					PageSize:  10,
					FullEmail: true,
				}, "").
				Return(&accountcli.GetUsersResp{
					Items: []*accountcli.User{
						{Email: "founder@example.com", FirstName: "Alex", LastName: "Wang"},
					},
				}, nil)

			contactName, contactFirstName := service.resolveGTMContactInfo(context.Background(), 123, "founder@example.com")
			Expect(contactName).ToNot(BeNil())
			Expect(*contactName).To(Equal("Alex Wang"))
			Expect(contactFirstName).ToNot(BeNil())
			Expect(*contactFirstName).To(Equal("Alex"))
		})

		It("falls back when no user matches", func() {
			ctrl := gomock.NewController(GinkgoT())
			accountCli := accountcli.NewMockClient(ctrl)
			service := &svc{accountCli: accountCli, logger: logr.Discard()}

			accountCli.EXPECT().
				GetTenantUsers(gomock.Any(), "123", accountcli.GetUsersReq{
					Search:    "founder@example.com",
					Page:      1,
					PageSize:  10,
					FullEmail: true,
				}, "").
				Return(&accountcli.GetUsersResp{
					Items: []*accountcli.User{
						{Email: "someone-else@example.com", FirstName: "Alex", LastName: "Wang"},
					},
				}, nil)

			contactName, contactFirstName := service.resolveGTMContactInfo(context.Background(), 123, "founder@example.com")
			Expect(contactName).ToNot(BeNil())
			Expect(*contactName).To(Equal("Founder"))
			Expect(contactFirstName).To(BeNil())
		})

		It("falls back when the matched user has no first name", func() {
			ctrl := gomock.NewController(GinkgoT())
			accountCli := accountcli.NewMockClient(ctrl)
			service := &svc{accountCli: accountCli, logger: logr.Discard()}

			accountCli.EXPECT().
				GetTenantUsers(gomock.Any(), "123", accountcli.GetUsersReq{
					Search:    "alex.wang@example.com",
					Page:      1,
					PageSize:  10,
					FullEmail: true,
				}, "").
				Return(&accountcli.GetUsersResp{
					Items: []*accountcli.User{
						{Email: "alex.wang@example.com", LastName: "Wang"},
					},
				}, nil)

			contactName, contactFirstName := service.resolveGTMContactInfo(context.Background(), 123, "alex.wang@example.com")
			Expect(contactName).ToNot(BeNil())
			Expect(*contactName).To(Equal("Alex Wang"))
			Expect(contactFirstName).To(BeNil())
		})
	})
})
