package tasks

import (
	"context"
	"fmt"
	"math/rand"
	"strings"
	"time"

	"github.com/getsentry/sentry-go"
	"github.com/google/uuid"
	"github.com/rs/zerolog/log"
	"github.com/warmbly/warmbly/internal/config"
	"github.com/warmbly/warmbly/internal/errx"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/repository"
	"github.com/warmbly/warmbly/internal/tasks/proto"
)

func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	// STEP 1: Parse task ID
	taskID, err := uuid.Parse(task.TaskId)
	if err != nil {
		sentry.CaptureException(err)
		return errx.New(errx.BadRequest, "invalid task ID")
	}

	executionKey := "warmup:" + taskID.String()
	executionStatus := "failed"
	// lane distinguishes a normal user-enabled warmup send ("warmup") from a
	// health-check send kept flowing only because the mailbox backs a live
	// campaign ("health_check"). Set once the account is loaded; the defer
	// reads it at completion time.
	lane := "warmup"
	if s.advanced != nil {
		duplicate, xerr := s.advanced.StartTaskExecution(ctx, taskID, executionKey, map[string]interface{}{
			"task_type": "warmup",
		})
		if xerr != nil {
			return xerr
		}
		if duplicate {
			return nil
		}
		defer func() {
			_ = s.advanced.CompleteTaskExecution(ctx, taskID, executionKey, executionStatus, map[string]interface{}{
				"task_type": "warmup",
				"lane":      lane,
			})
		}()
	}

	// STEP 2: Load task record
	taskRecord, err := s.taskRepo.GetTask(ctx, taskID)
	if err != nil {
		sentry.CaptureException(err)
		return errx.InternalError()
	}

	if taskRecord == nil {
		return errx.ErrNotFound
	}

	if taskRecord.Status != "pending" {
		log.Info().
			Str("task_id", taskID.String()).
			Str("status", taskRecord.Status).
			Msg("warmup task skipped: task not in pending state")
		executionStatus = "completed"
		return nil
	}

	// STEP 3: Load email account
	account, xerr := s.emailRepo.GetByID(ctx, taskRecord.EmailAccountID)
	if xerr != nil {
		return xerr
	}
	if account == nil {
		return errx.ErrNotFound
	}
	// A mailbox enrolled in Warmbly Cloud is warmed there; the local chain ends here.
	if s.cloudLink != nil && s.cloudLink.IsEnrolled(ctx, account.ID) {
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
		executionStatus = "skipped_cloud_warmup"
		return nil
	}
	// Keep the warmup chain alive while the mailbox is actively warming OR
	// while it backs a live campaign (the low-volume health-check lane). Once
	// neither holds, the chain is allowed to wind down.
	activelyWarming := account.IsWarmingActive()
	inCampaign := false
	if !activelyWarming {
		inCampaign = s.accountInActiveCampaign(ctx, account.ID)
	}
	if account.Status != "active" || (!activelyWarming && !inCampaign) {
		if s.warmupHealth != nil {
			if account.Status == "active" && account.OrganizationID != nil && s.featureGate != nil {
				canWarmup, xerr := s.featureGate.CanUseWarmup(ctx, *account.OrganizationID)
				switch {
				case xerr != nil:
					// Entitlement unknown: leave the membership alone rather than let a blip evict.
				case canWarmup:
					_ = s.warmupHealth.EnsurePoolMembershipWithRole(ctx, account.ID, s.resolveWarmupPoolType(ctx, account), "recipient_only")
				default:
					_ = s.warmupHealth.RemoveFromAllPools(ctx, account.ID)
				}
			} else {
				_ = s.warmupHealth.RemoveFromAllPools(ctx, account.ID)
			}
		}
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
		executionStatus = "completed"
		return nil
	}
	if !activelyWarming && inCampaign {
		lane = "health_check"
		log.Info().
			Str("task_id", taskID.String()).
			Str("email_account_id", account.ID.String()).
			Msg("warmup health-check send (mailbox in active campaign, warmup off)")
	}

	// Tenancy gate: warmup entitlement, pool tier and the shared-pool safety
	// signals are all org-scoped, so a mailbox with no workspace cannot be
	// checked against any of them. Fail closed — it leaves the pool and stops
	// warming rather than warming as if it were entitled.
	if account.OrganizationID == nil {
		if s.warmupHealth != nil {
			_ = s.warmupHealth.RemoveFromAllPools(ctx, account.ID)
		}
		sentry.CaptureException(fmt.Errorf("email account %s reached warmup with no organization", account.ID))
		log.Error().Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).
			Msg("warmup blocked: mailbox has no organization, entitlement and pool policy cannot be checked")
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_no_warmup_access")
		executionStatus = "completed"
		return nil
	}

	// STEP 3.5: Check if organization can use warmup (only paid orgs)
	if s.featureGate != nil {
		canWarmup, entitlementErr := s.featureGate.CanUseWarmup(ctx, *account.OrganizationID)
		if !canWarmup {
			// Only a definite "not entitled" evicts; the send is skipped either way.
			if s.warmupHealth != nil && entitlementErr == nil {
				_ = s.warmupHealth.RemoveFromAllPools(ctx, account.ID)
			}
			// Organization cannot use warmup - skip this task
			s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_no_warmup_access")
			executionStatus = "completed"
			return nil
		}
	}

	poolType := s.resolveWarmupPoolType(ctx, account)

	// STEP 3.6: Sending-domain authentication. Pool reputation is shared, so an
	// unauthenticated sender damages every other participant, not just itself.
	// Demoted to recipient_only rather than removed: receiving harms nobody and
	// the mailbox promotes straight back once its DNS is fixed.
	if s.domainAuthBlocked(ctx, account) {
		if s.warmupHealth != nil {
			_ = s.warmupHealth.EnsurePoolMembershipWithRole(ctx, account.ID, poolType, "recipient_only")
		}
		log.Warn().
			Str("task_id", taskID.String()).
			Str("email_account_id", account.ID.String()).
			Str("email", account.Email).
			Msg("warmup send skipped: sending domain fails authentication")
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_domain_auth")
		// Re-seed the chain, or the mailbox never warms again even after its
		// DNS is fixed: this task has no successor and nothing else creates
		// one. The recheck cadence is the partner-exhaustion one (4-8h), which
		// is the right order of magnitude for a DNS change to propagate.
		if createErr := s.createWarmupTask(ctx, account.ID, warmupPartnerRecheckTime()); createErr != nil {
			log.Warn().Err(createErr).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to reschedule warmup task after domain-auth gate")
		}
		executionStatus = "completed"
		return nil
	}

	// A suspended workspace stops warming too: warmup is outbound mail from the
	// same domains, so leaving it running would keep spending the reputation
	// the suspension exists to protect.
	if s.orgBlocksSending(ctx, account.OrganizationID) {
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_org_suspended")
		executionStatus = "completed"
		return nil
	}

	if s.warmupHealth != nil {
		if err := s.warmupHealth.EnsurePoolMembershipWithRole(ctx, account.ID, poolType, "sender_receiver"); err != nil {
			return err
		}

		canParticipate, _, err := s.warmupHealth.CanParticipate(ctx, account.ID, poolType)
		if err != nil {
			return err
		}
		if !canParticipate {
			_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_warmup_protected")
			s.scheduleWarmupRecovery(ctx, account.ID, poolType)
			executionStatus = "completed"
			return nil
		}
	}

	// STEP 4: Mark task as active (with advisory lock)
	if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil {
		sentry.CaptureException(err)
		return errx.InternalError()
	}

	// STEP 5: Select warmup partner. A task directed at one partner is a
	// reply-back: someone mailed this mailbox and it is answering them, which
	// is what makes a thread read as a conversation instead of two monologues.
	// The directed partner still passes the same health gate as a drawn one.
	partner := s.directedWarmupPartner(ctx, taskID, poolType)
	// A directed task IS the reply; the reply-rate draw already happened when
	// it was scheduled. Rolling again here would square the rate, so a 30%
	// reply rate would answer 9% of the time.
	directedReply := partner != nil
	if partner == nil {
		partner, err = s.selectWarmupPartner(ctx, *account)
	}
	if err != nil {
		_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_warmup_protected")
		nextTime, scheduleErr := s.scheduler.CalculateNextWarmupTime(ctx, account.ID)
		if scheduleErr != nil {
			nextTime = warmupPartnerRecheckTime()
		}
		if nextTime.Before(time.Now().Add(4 * time.Hour)) {
			nextTime = warmupPartnerRecheckTime()
		}
		if createErr := s.createWarmupTask(ctx, account.ID, nextTime); createErr != nil {
			log.Warn().Err(createErr).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to reschedule warmup task after partner exhaustion")
		}
		executionStatus = "completed"
		return nil
	}

	// STEP 6: Determine if this should be a reply or a new warmup email.
	// Replies advance the exact cached conversation plan used by the parent.
	replyRate := account.WarmupReplyRate
	shouldReply := directedReply || rand.Float64()*100 < float64(replyRate)
	var subject, emailBody, conversationTheme, contentSource string
	var conversationID *uuid.UUID
	var conversationTurn int
	var inReplyTo string

	if shouldReply {
		candidate, replyErr := s.warmupRepo.GetLatestReplyCandidate(ctx, partner.ID, account.ID)
		if replyErr == nil && candidate != nil && candidate.MessageID != "" {
			inReplyTo = candidate.MessageID
			subject = strings.TrimSpace(candidate.Subject)
			if subject == "" {
				subject = generateWarmupSubject()
			}
			// "Re:" is legitimate here — this is a genuine reply with a real
			// In-Reply-To header (synthesizeWarmupSubject no longer fabricates
			// "Re:" on first-touch sends).
			if !strings.HasPrefix(strings.ToLower(subject), "re:") {
				subject = "Re: " + subject
			}
			nextTurn := candidate.ConversationTurn + 1
			var conv Conversation
			if candidate.ContentSource == models.WarmupContentSourceAI && candidate.ConversationID != nil && s.warmupContentRepo != nil {
				cached, getErr := s.warmupContentRepo.GetConversation(ctx, *candidate.ConversationID)
				if getErr == nil && cached != nil && cached.LintPassed && cached.ReplyEligible {
					conv = Conversation{
						ID:          cached.ID,
						Theme:       cached.Theme,
						Description: cached.Description,
						Messages:    cached.Messages,
					}
					conversationTheme = cached.Theme
					contentSource = models.WarmupContentSourceAI
					conversationID = candidate.ConversationID
				}
			} else {
				conv = conversationForTheme(candidate.ConversationTheme)
				conversationTheme = conv.Theme
				contentSource = models.WarmupContentSourceStatic
				conversationID = candidate.ConversationID
			}

			var rendered bool
			emailBody, rendered = GenerateConversationReplyEmail(conv, *account, nextTurn)
			if rendered {
				conversationTurn = nextTurn
			} else {
				// The thread is exhausted or no longer safe. Start a fresh one
				// instead of repeating a reply or switching topics mid-thread.
				shouldReply = false
				inReplyTo = ""
			}
		} else {
			shouldReply = false
		}
	}

	// STEP 7: Build a new warmup message when not replying. Content comes from
	// the AI bank (segment-aware) when enabled, else the static library.
	if !shouldReply {
		content := s.pickNewWarmupContent(ctx, *account)
		subject = content.subject
		emailBody = content.body
		conversationTheme = content.theme
		contentSource = content.contentSource
		conversationID = content.conversationID
	}

	// STEP 7.5: Content-safety lint. Warmup mail must look unremarkable; if the
	// chosen content trips the lint (most likely AI drift) fall back to clean
	// static content so we never send spammy-looking warmup.
	if err := lintWarmupContent(subject, emailBody, shouldReply); err != nil {
		log.Warn().Err(err).
			Str("email_account_id", account.ID.String()).
			Str("content_source", contentSource).
			Msg("warmup content failed lint; falling back to static")
		conv := randomWarmupConversation()
		conversationTheme = conv.Theme
		fallbackID := conv.ID
		conversationID = &fallbackID
		conversationTurn = 0
		contentSource = models.WarmupContentSourceStatic
		shouldReply = false
		inReplyTo = ""
		subject = generateWarmupSubject()
		emailBody = GenerateConversationEmail(conv, *account, false)
		if err2 := lintWarmupContent(subject, emailBody, false); err2 != nil {
			subject = "Quick note"
			emailBody = GenerateConversationEmail(Conversation{
				Theme:       "checkin",
				Description: "Just checking in — hope all is well.",
				Messages:    []string{"How have things been lately?"},
			}, *account, false)
		}
	}

	// STEP 9: Generate Message-ID
	messageID := generateMessageID(account.Email)
	// Persist it now so the reply path (GetLatestReplyCandidate, which filters
	// message_id <> '') can find this send as a thread parent on a later turn.
	// Without this the warmup reply/threading path never fires.
	if err := s.taskRepo.UpdateTaskMessageID(ctx, taskID, messageID); err != nil {
		log.Warn().Err(err).Str("task_id", taskID.String()).Msg("Failed to persist warmup task message_id")
	}

	// STEP 9.5: Generate warmup verification token
	var warmupTokenStr string
	warmupToken := uuid.New()
	tokenRecord := &models.WarmupToken{
		Token:              warmupToken,
		TaskID:             taskID,
		SenderAccountID:    account.ID,
		RecipientAccountID: partner.ID,
		ConversationTheme:  conversationTheme,
		ContentSource:      contentSource,
		ConversationID:     conversationID,
		ConversationTurn:   conversationTurn,
		// The recipient falls back to matching on the subject when the verify
		// header does not survive delivery (Graph strips it).
		Subject:   subject,
		ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
	}
	if err := s.warmupRepo.CreateWarmupToken(ctx, tokenRecord); err != nil {
		log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to create warmup token")
	} else {
		warmupTokenStr = warmupToken.String()
	}

	// STEP 10: Send warmup email to worker via Kafka
	emailMsg := EmailMessage{
		From:        account.Email,
		To:          []string{partner.Email},
		Subject:     subject,
		BodyHTML:    "", // Warmup emails are plaintext only
		BodyPlain:   emailBody,
		InReplyTo:   inReplyTo,
		MessageID:   messageID,
		IsWarmup:    true,
		Tracking:    nil, // No tracking for warmup
		WarmupToken: warmupTokenStr,
	}

	if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil {
		s.taskRepo.RecordTaskFailure(ctx, taskID, "Send failed", err.Error())
		if s.advanced != nil {
			_ = s.advanced.CaptureTaskDeadLetter(ctx, taskID, "warmup", map[string]interface{}{
				"partner_email": partner.Email,
			}, err.Error(), 1)
			_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "dead_lettered")
		}
		return nil
	}

	// STEP 11: Update task record
	taskRecord.MessageID = messageID
	taskRecord.Status = "completed"

	// STEP 12: Update warmup statistics
	if err := s.warmupRepo.IncrementDailyCount(ctx, account.ID, time.Now()); err != nil {
		log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to increment warmup daily count")
	}
	// Track replies separately so warmup reply analytics (emails_replied) is no
	// longer always zero. Conversational replies are a healthy-traffic signal.
	if shouldReply {
		if err := s.warmupRepo.IncrementReplyCount(ctx, account.ID, time.Now()); err != nil {
			log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to increment warmup reply count")
		}
	}

	// STEP 13: Mark task completed (with advisory lock)
	if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil {
		sentry.CaptureException(err)
		return errx.InternalError()
	}

	// STEP 14: Publish events
	s.publishWarmupEmailSentEvent(ctx, taskRecord, account, partner, shouldReply)

	// STEP 15: Calculate next warmup time and create new task
	nextTime, err := s.scheduler.CalculateNextWarmupTime(ctx, account.ID)
	if err != nil {
		log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to calculate next warmup time")
		return nil
	}

	if err := s.createWarmupTask(ctx, account.ID, nextTime); err != nil {
		log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to create next warmup task")
	}

	executionStatus = "completed"
	return nil
}

// accountInActiveCampaign reports whether the mailbox currently backs at
// least one active campaign. Failing closed on error keeps the health-check
// lane conservative — a transient DB error just lets the chain wind down if
// warmup is otherwise off.
func (s *tasksService) accountInActiveCampaign(ctx context.Context, accountID uuid.UUID) bool {
	if s.campaignRepo == nil {
		return false
	}
	count, err := s.campaignRepo.CountActiveCampaignsForAccount(ctx, accountID)
	if err != nil {
		return false
	}
	return count > 0
}

// recentPartnerWindow controls how long a partner stays excluded from
// re-selection after being used. Bumped from 24h to 72h so a small pool
// does not rotate through the same handful of mailboxes every day.
const recentPartnerWindow = 72 * time.Hour

// recentDomainWindow controls the lookback for the domain-distribution
// histogram. A week is long enough to smooth out daily randomness while
// still reflecting current behaviour.
const recentDomainWindow = 7 * 24 * time.Hour

// smallPoolWarnThreshold defines the participant count below which we log
// a warning. Tiny pools force partner reuse and create obvious patterns
// that mailbox providers can cluster on.
const smallPoolWarnThreshold = 8

// partnerDiversityWindow / partnerMaxSharedWindow set the explicit
// partner-diversity target: within partnerDiversityWindow, a sender should not
// send to the same partner more than partnerMaxSharedWindow times. Over-used
// partners are demoted to the fallback tier so warmup traffic spreads across
// many partners rather than forming a tight reciprocal pair (a closed-loop
// graph signal). This is a soft target — it only applies while enough other
// partners remain available.
const (
	partnerDiversityWindow = 7 * 24 * time.Hour
	partnerMaxSharedWindow = 3
)

func warmupPartnerRecheckTime() time.Time {
	return time.Now().Add(time.Duration(240+rand.Intn(240)) * time.Minute)
}

// selectWarmupPartner selects a warmup partner from the pool, preferring
// partners on under-represented recipient domains to avoid concentrating
// traffic on a single provider (e.g. all-Gmail warmup loops).
func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (*Email, error) {
	poolType := s.resolveWarmupPoolType(ctx, &account)

	participantIDs, err := s.warmupRepo.GetPoolRecipientParticipants(ctx, poolType, true)
	if err != nil {
		return nil, err
	}

	// A thin tier borrows the other tier's proven mailboxes; the SQL gates age and health.
	fallbackIDs := []uuid.UUID{}
	if len(participantIDs) < config.WarmupPoolTierFallbackFloor {
		if extra, ferr := s.warmupRepo.GetPoolFallbackRecipients(ctx, poolType, config.WarmupPoolFallbackMinAgeDays*24*time.Hour); ferr == nil {
			fallbackIDs = extra
			participantIDs = append(participantIDs, extra...)
		}
	}

	if len(participantIDs) == 0 {
		return nil, fmt.Errorf("no warmup partners available")
	}

	if len(participantIDs) < smallPoolWarnThreshold {
		log.Warn().
			Int("participants", len(participantIDs)).
			Str("pool", poolType).
			Str("email_account_id", account.ID.String()).
			Msg("Warmup pool below diversity threshold; partner reuse likely")
	}

	domainsByID, err := s.warmupRepo.GetPoolParticipantDomains(ctx, poolType, true)
	if err != nil {
		// Diversity weighting is best-effort. Fall back to uniform on lookup error.
		domainsByID = nil
	}
	if len(fallbackIDs) > 0 && domainsByID != nil {
		if other, oerr := s.warmupRepo.GetPoolParticipantDomains(ctx, otherPoolType(poolType), true); oerr == nil {
			for _, id := range fallbackIDs {
				if d, ok := other[id]; ok {
					domainsByID[id] = d
				}
			}
		}
	}

	// This sender's recent record per recipient provider. Best-effort: a lookup
	// error leaves the maps empty and weighting degrades to domain diversity.
	// Providers are read UNFILTERED so every candidate the selector can offer
	// resolves; a missing one would score an unpenalized 1.0.
	providersByID, provErr := s.warmupRepo.GetPoolParticipantProviders(ctx, poolType, false)
	if provErr != nil {
		providersByID = nil
	}
	if len(fallbackIDs) > 0 && providersByID != nil {
		if other, oerr := s.warmupRepo.GetPoolParticipantProviders(ctx, otherPoolType(poolType), false); oerr == nil {
			for _, id := range fallbackIDs {
				if pv, ok := other[id]; ok {
					providersByID[id] = pv
				}
			}
		}
	}
	placementByProvider, placeErr := s.warmupRepo.SenderPlacementByProvider(ctx, account.ID, time.Now().Add(-providerPlacementWindow))
	if placeErr != nil {
		placementByProvider = nil
	}

	// Load customer routing rules (premium pool only — free pool ignores
	// rules since trial mailboxes don't need provider-shape preferences).
	var routingRules []models.WarmupRoutingRule
	var emailsByID map[uuid.UUID]string
	if poolType == "premium" && s.warmupRoutingRepo != nil && account.OrganizationID != nil {
		rules, ruleErr := s.warmupRoutingRepo.ListForOrganization(ctx, *account.OrganizationID)
		if ruleErr == nil && len(rules) > 0 {
			routingRules = rules
			if e, eErr := s.warmupRepo.GetPoolParticipantEmails(ctx, poolType, true); eErr == nil {
				emailsByID = e
			}
		}
	}

	recentPartnerSet := map[uuid.UUID]struct{}{}
	recentPartnerIDs, err := s.warmupRepo.GetRecentlyUsedPartners(ctx, account.ID, time.Now().Add(-recentPartnerWindow))
	if err == nil {
		for _, pid := range recentPartnerIDs {
			recentPartnerSet[pid] = struct{}{}
		}
	}

	todayPartnerSet := map[uuid.UUID]struct{}{}
	todayPartnerIDs, err := s.warmupRepo.GetRecentlyUsedPartners(ctx, account.ID, time.Now().Truncate(24*time.Hour))
	if err == nil {
		for _, pid := range todayPartnerIDs {
			todayPartnerSet[pid] = struct{}{}
		}
	}

	domainCounts, err := s.warmupRepo.GetRecentPartnerDomainCounts(ctx, account.ID, time.Now().Add(-recentDomainWindow))
	if err != nil {
		domainCounts = nil
	}

	// Explicit partner-diversity target: how many times each partner has been
	// used in the diversity window, so partners the sender already leans on
	// heavily get demoted out of the preferred tier.
	partnerCounts, err := s.warmupRepo.GetRecentPartnerCounts(ctx, account.ID, time.Now().Add(-partnerDiversityWindow))
	if err != nil {
		partnerCounts = nil
	}

	var availablePartners []uuid.UUID
	var fallbackPartners []uuid.UUID
	for _, id := range participantIDs {
		if id == account.ID {
			continue
		}
		if _, usedToday := todayPartnerSet[id]; usedToday {
			continue
		}
		fallbackPartners = append(fallbackPartners, id)
		_, recentlyUsed := recentPartnerSet[id]
		overUsed := partnerCounts[id] >= partnerMaxSharedWindow
		if !recentlyUsed && !overUsed {
			availablePartners = append(availablePartners, id)
		}
	}

	if len(availablePartners) == 0 && len(fallbackPartners) > 0 {
		availablePartners = fallbackPartners
	}

	if len(availablePartners) == 0 {
		return nil, fmt.Errorf("no available warmup partners")
	}

	// Pick a partner, then gate it through the SAME health re-evaluation the
	// sender passes (email_task STEP 5). The recipient-selection SQL re-admits a
	// row the instant blocked_until elapses — before the hourly sweep
	// reclassifies it — so without this gate a just-expired quarantined/blocked
	// mailbox could be chosen as a recipient with no re-qualification.
	// CanParticipate re-evaluates and forces just-unblocked mailboxes into
	// probation, matching the CLAUDE.md re-entry policy on the recipient surface.
	for attempts := 0; attempts < 5 && len(availablePartners) > 0; attempts++ {
		partnerID := pickWeightedPartner(availablePartners, partnerSignals{
			domainsByID:         domainsByID,
			domainCounts:        domainCounts,
			providersByID:       providersByID,
			placementByProvider: placementByProvider,
			rules:               routingRules,
			senderEmail:         account.Email,
			emailsByID:          emailsByID,
		})

		if s.warmupHealth != nil {
			if ok, _, _ := s.warmupHealth.CanParticipate(ctx, partnerID, poolType); !ok {
				availablePartners = removePartnerID(availablePartners, partnerID)
				continue
			}
		}

		partner, err := s.emailRepo.GetByID(ctx, partnerID)
		if err != nil {
			return nil, err
		}
		return partner, nil
	}

	return nil, fmt.Errorf("no eligible warmup partners after health gate")
}

// removePartnerID returns ids without the first occurrence of target. Used to
// drop a partner that failed the health gate before re-picking.
func removePartnerID(ids []uuid.UUID, target uuid.UUID) []uuid.UUID {
	out := make([]uuid.UUID, 0, len(ids))
	for _, id := range ids {
		if id != target {
			out = append(out, id)
		}
	}
	return out
}

const (
	// weight *= 1/(1 + k*rate). Never an exclusion: a sender that stops mailing
	// a provider cannot discover it recovered there.
	providerPlacementPenaltyK = 4.0
	// Below this sample a provider's rate is noise, not a pattern.
	providerPlacementMinSends = 5
	providerPlacementWindow   = 7 * 24 * time.Hour
)

// partnerSignals are the per-pick inputs to partner weighting.
type partnerSignals struct {
	domainsByID  map[uuid.UUID]string
	domainCounts map[string]int
	// Keyed by who RUNS the recipient's mail (models.ClassifyProvider).
	providersByID       map[uuid.UUID]string
	placementByProvider map[string]repository.ProviderPlacementStat
	rules               []models.WarmupRoutingRule
	senderEmail         string
	emailsByID          map[uuid.UUID]string
}

// providerPenalty weights sending to one provider by how this sender has
// recently landed there. 1.0 on too small a sample, and on every error path.
func (sig partnerSignals) providerPenalty(partnerID uuid.UUID) float64 {
	provider, ok := sig.providersByID[partnerID]
	if !ok || len(sig.placementByProvider) == 0 {
		return 1.0
	}
	stat, ok := sig.placementByProvider[provider]
	if !ok || stat.Sends < providerPlacementMinSends {
		return 1.0
	}
	return 1.0 / (1.0 + providerPlacementPenaltyK*stat.Rate())
}

// pickWeightedPartner picks a partner ID using a composite weight:
//   - inverse-frequency on the partner's recipient domain (diversity)
//   - this sender's recent junk rate at the partner's provider (feedback)
//   - customer-defined routing rule multipliers (preference)
//
// Rules are evaluated in priority order; the first matching rule for a
// (sender, recipient) pair applies its weight multiplier. A rule with
// weight=0 hard-excludes the pair. Falls back to uniform when no signals
// are available.
func pickWeightedPartner(candidates []uuid.UUID, sig partnerSignals) uuid.UUID {
	if len(candidates) == 1 {
		return candidates[0]
	}
	if len(sig.domainsByID) == 0 && len(sig.rules) == 0 && len(sig.placementByProvider) == 0 {
		return candidates[rand.Intn(len(candidates))]
	}

	weights := make([]float64, len(candidates))
	var total float64
	for i, id := range candidates {
		domain := sig.domainsByID[id]
		// Diversity base weight.
		w := 1.0 / float64(1+sig.domainCounts[domain])

		// Per-provider placement feedback.
		w *= sig.providerPenalty(id)

		// Routing rule multiplier (premium pool only, when configured).
		if len(sig.rules) > 0 && len(sig.emailsByID) > 0 {
			if recipientEmail, ok := sig.emailsByID[id]; ok {
				w *= routingMultiplier(sig.rules, sig.senderEmail, recipientEmail)
			}
		}

		weights[i] = w
		total += w
	}

	if total <= 0 {
		// All candidates hard-excluded by rules. Fall back to uniform so
		// the system still warms up rather than stalling on misconfigured rules.
		return candidates[rand.Intn(len(candidates))]
	}

	r := rand.Float64() * total
	var cum float64
	for i, w := range weights {
		cum += w
		if r <= cum {
			return candidates[i]
		}
	}
	return candidates[len(candidates)-1]
}

// routingMultiplier returns the weight multiplier from the first matching
// rule in priority order (lowest priority value wins). 1.0 when no rule
// matches — neutral, so unmatched pairs neither preferred nor penalized.
func routingMultiplier(rules []models.WarmupRoutingRule, senderEmail, recipientEmail string) float64 {
	for i := range rules {
		if rules[i].Matches(senderEmail, recipientEmail) {
			return rules[i].Weight
		}
	}
	return 1.0
}

func (s *tasksService) resolveWarmupPoolType(ctx context.Context, account *Email) string {
	if account == nil {
		return "premium"
	}
	// No organization means no entitlement to check, so the mailbox gets the
	// lower-trust pool rather than defaulting into the paid one.
	if account.OrganizationID == nil {
		return "free"
	}
	// A restricted organization leaves the paid pool whatever it pays. Checked
	// before the stored tier, which is never empty and would short-circuit it.
	if s.orgSuspendedOrRestricted(ctx, *account.OrganizationID) {
		return "free"
	}
	if account.WarmupPoolType != "" {
		return account.WarmupPoolType
	}
	if s.featureGate != nil {
		isPaid, xerr := s.featureGate.IsPaidOrganization(ctx, *account.OrganizationID)
		if xerr == nil && !isPaid {
			return "free"
		}
	}
	return "premium"
}

func (s *tasksService) scheduleWarmupRecovery(ctx context.Context, accountID uuid.UUID, poolType string) {
	if s.warmupRepo == nil {
		return
	}

	health, err := s.warmupRepo.GetParticipantHealth(ctx, accountID, poolType)
	if err != nil || health == nil || health.BlockedUntil == nil {
		return
	}
	if !health.BlockedUntil.After(time.Now()) {
		return
	}

	if err := s.createWarmupTask(ctx, accountID, *health.BlockedUntil); err != nil {
		log.Warn().Err(err).Str("email_account_id", accountID.String()).Str("pool_type", poolType).Msg("Failed to create warmup recovery task")
	}
}

// EnsureWarmupScheduled makes sure the mailbox has a pending warmup task so
// its warmup / health-check chain is running. Safe to call repeatedly: the
// per-mailbox advisory lock in CreateWarmupTaskWithLock means a duplicate
// pending task is never created. Returns ErrWarmupNotEnabled (benign) when
// the mailbox is neither warming nor backing a live campaign.
func (s *tasksService) EnsureWarmupScheduled(ctx context.Context, accountID uuid.UUID) error {
	nextTime, err := s.scheduler.CalculateNextWarmupTime(ctx, accountID)
	if err != nil {
		return err
	}
	return s.createWarmupTask(ctx, accountID, nextTime)
}

// createWarmupTask creates a new warmup task in GCP Cloud Tasks
func (s *tasksService) createWarmupTask(ctx context.Context, accountID uuid.UUID, scheduleTime time.Time) error {
	// Create task in database
	newTaskID := uuid.New()
	newTask := &Task{
		ID:             newTaskID,
		TaskType:       "warmup",
		EmailAccountID: accountID,
		Status:         "pending",
		ScheduledAt:    &scheduleTime,
	}

	// Create warmup task entry
	warmupTask := &WarmupTask{
		TaskID: newTaskID,
	}

	created, err := s.taskRepo.CreateWarmupTaskWithLock(ctx, newTask, warmupTask)
	if err != nil {
		return err
	}
	if !created {
		return nil
	}

	// Create GCP Cloud Task
	processTask := &proto.ProcessTask{
		TaskId: newTaskID.String(),
	}

	cloudTaskName, err := s.tasksClient.CreateTask(ctx, processTask, scheduleTime)
	if err != nil {
		return err
	}

	// Update task with cloud task name
	if err := s.taskRepo.UpdateTaskScheduledAt(ctx, newTaskID, scheduleTime, cloudTaskName); err != nil {
		return err
	}

	return nil
}

// publishWarmupEmailSentEvent publishes warmup email sent event (realtime) and
// an opt-in firehose webhook (warmup.email_sent).
func (s *tasksService) publishWarmupEmailSentEvent(ctx context.Context, task *Task, account *Email, partner *Email, isReply bool) {
	if s.eventsPublisher != nil {
		if err := s.eventsPublisher.PublishWarmupEmailSent(ctx, task, account, partner, isReply); err != nil {
			log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("Failed to publish warmup email sent event")
		}
	}

	if s.advanced != nil && account != nil && account.OrganizationID != nil {
		s.advanced.EmitCampaignEvent(ctx, *account.OrganizationID, models.WebhookEventWarmupEmailSent, map[string]any{
			"email_account_id": account.ID.String(),
			"is_reply":         isReply,
		})
	}
}

// generateWarmupSubject picks a warmup subject. To reduce content
// fingerprinting risk, ~40% of the time we synthesize a subject from
// slot templates (yielding thousands of unique strings) instead of
// returning a literal from the static set.
func generateWarmupSubject() string {
	if rand.Float64() < 0.4 {
		if s := synthesizeWarmupSubject(); s != "" {
			return s
		}
	}
	subjects := warmupSubjectLiterals()
	return subjects[rand.Intn(len(subjects))]
}

// synthesizeWarmupSubject composes a subject from slot fragments. With
// the current slot dictionaries this yields several thousand distinct
// strings, which is harder for a vendor corpus-classifier to fingerprint.
func synthesizeWarmupSubject() string {
	templates := []string{
		"{adj} {noun}",
		"{adj} {noun} {timeRef}",
		"{verb} {noun}",
		"{verb} on {noun}",
		"{question} about {noun}",
		"{timeRef} {noun}",
		"{noun} {timeRef}",
		// NB: no "Re:"/"Fwd:" template here — a fabricated reply prefix on a
		// first-touch send is a deception signal and CAN-SPAM exposure. The
		// genuine reply path adds "Re:" itself when there is a real
		// In-Reply-To header.
	}
	adj := []string{"quick", "small", "short", "tiny", "casual", "friendly", "useful", "interesting", "brief", "minor"}
	noun := []string{"check-in", "follow up", "note", "ping", "thought", "update", "idea", "heads up", "favor", "question", "nudge", "share"}
	verb := []string{"Following up", "Circling back", "Wanted your take", "Touching base", "Picking up", "Adding"}
	question := []string{"Quick question", "Curious", "Wanted your view", "Thought"}
	timeRef := []string{"this week", "before EOD", "when free", "this morning", "today", "later", "if useful"}

	tpl := templates[rand.Intn(len(templates))]
	r := strings.NewReplacer(
		"{adj}", capitalize(adj[rand.Intn(len(adj))]),
		"{noun}", noun[rand.Intn(len(noun))],
		"{verb}", verb[rand.Intn(len(verb))],
		"{question}", question[rand.Intn(len(question))],
		"{timeRef}", timeRef[rand.Intn(len(timeRef))],
	)
	return strings.TrimSpace(r.Replace(tpl))
}

func capitalize(s string) string {
	if s == "" {
		return ""
	}
	return strings.ToUpper(s[:1]) + s[1:]
}

func warmupSubjectLiterals() []string {
	return []string{
		// Casual check-ins
		"Quick question",
		"Following up",
		"Checking in",
		"Quick update",
		"Hope your week is going well",
		"Touching base",
		"Small update",
		"Just wanted to say hi",
		"How have you been?",
		"Long time no chat",

		// Sharing / referencing
		"Thought you might find this interesting",
		"Wanted to share this",
		"Good read I came across",
		"Saw this and thought of you",
		"Worth a look",
		"You might like this",
		"Had to share this with you",
		"Reminded me of our last chat",
		"Came across something relevant",

		// Engagement / questions
		"Any thoughts on this?",
		"A quick favor",
		"When you get a chance",
		"Just a thought",
		"Would love your input",
		"Curious what you think",
		"Got a minute?",
		"Need a quick opinion",

		// Thread-like / follow-up
		"Re: our conversation",
		"One more thing",
		"Circling back",
		"Heads up",
		"Forgot to mention",
		"One thing I meant to ask",
		"Adding to what we discussed",
		"Quick follow up from earlier",

		// Seasonal / time-based
		"Happy Monday",
		"End of week thought",
		"Midweek check-in",
		"Hope the quarter is going well",
		"Starting the week strong",

		// Professional / value-add
		"Resource you might find useful",
		"Something worth bookmarking",
		"Interesting perspective on this",
		"Quick tip I picked up",
		"Thought this was insightful",
	}
}

// conversationForTheme returns a conversation matching the requested theme,
// falling back to a random conversation if the theme is empty or unknown.
// Use this when replying so the reply body stays on-topic with the original
// thread instead of jumping subjects mid-conversation.
func conversationForTheme(theme string) Conversation {
	if theme == "" {
		return randomWarmupConversation()
	}
	var matches []Conversation
	for _, c := range warmupConversations() {
		if c.Theme == theme {
			matches = append(matches, c)
		}
	}
	if len(matches) == 0 {
		return randomWarmupConversation()
	}
	return matches[rand.Intn(len(matches))]
}

func randomWarmupConversation() Conversation {
	conversations := warmupConversations()
	return conversations[rand.Intn(len(conversations))]
}

// staticConvID derives a STABLE id for a static-library conversation from its
// content. Previously each call to warmupConversations() minted fresh uuid.New()
// ids, so the conversation_id recorded on a warmup token never matched across
// sends — defeating cohort correlation and dedupe. A deterministic id makes the
// static library traceable the same way the AI bank rows are.
func staticConvID(theme, description string) uuid.UUID {
	return uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmup-static:"+theme+"|"+description))
}

func warmupConversations() []Conversation {
	conversations := []Conversation{
		// Productivity & workflow
		{Theme: "productivity", Description: "I have been trying a few workflow changes and wondered what worked best for your week.", Messages: []string{"How do you structure focused work blocks?", "Do you batch similar tasks or tackle them as they come?"}},
		{Theme: "productivity", Description: "I started time-blocking my calendar this month and the results have been interesting so far.", Messages: []string{"Have you tried any time management methods that actually stuck?", "What does your typical morning routine look like?"}},
		{Theme: "automation", Description: "I automated a couple of repetitive tasks recently and it freed up more time than I expected.", Messages: []string{"Are there any repetitive tasks in your day that you have managed to streamline?"}},

		// Learning & growth
		{Theme: "learning", Description: "I came across a useful article and it got me curious about what resources you rely on lately.", Messages: []string{"Any newsletter or podcast you consistently recommend?", "What is the best thing you have learned recently?"}},
		{Theme: "learning", Description: "I have been dedicating an hour each week to learning something new and it has been surprisingly rewarding.", Messages: []string{"How do you make time for professional development?"}},
		{Theme: "courses", Description: "I just wrapped up an online course that was really practical and well-structured.", Messages: []string{"Have you taken any courses lately that were worth the investment?"}},

		// Collaboration & teams
		{Theme: "collaboration", Description: "I was thinking about how teams keep communication clear when work gets busy.", Messages: []string{"What has helped your team keep projects moving smoothly?", "How do you handle async communication across time zones?"}},
		{Theme: "meetings", Description: "We cut our meeting load in half last month and the team seems more productive overall.", Messages: []string{"How do you decide which meetings are actually necessary?", "Have you found a good balance between sync and async?"}},

		// Industry & trends
		{Theme: "industry", Description: "I noticed a shift in how people are approaching this topic and wanted to get your take.", Messages: []string{"Have you seen any changes in how your industry handles this?", "What trends are you paying attention to right now?"}},
		{Theme: "market", Description: "The market has been moving fast lately and I have been trying to figure out what matters most.", Messages: []string{"How are you adapting your approach given recent changes?"}},

		// Tools & technology
		{Theme: "tools", Description: "I recently switched up a few tools in my daily workflow and the difference has been noticeable.", Messages: []string{"What tools have made the biggest impact for you this year?", "Have you found a good alternative for that?"}},
		{Theme: "software", Description: "I have been testing a new project management setup and wondering if I am overcomplicating things.", Messages: []string{"What is your go-to for keeping projects organized?", "Do you prefer simple tools or full-featured platforms?"}},

		// Networking & catch-ups
		{Theme: "networking", Description: "It has been a while since we last connected and I wanted to see how things are going on your end.", Messages: []string{"Any new projects or goals you are excited about?", "What has been keeping you busy lately?"}},
		{Theme: "catchup", Description: "I was cleaning up my contacts list and realized we have not caught up in ages.", Messages: []string{"How has your year been going so far?", "Anything interesting happening on your side?"}},
		{Theme: "introduction", Description: "I met someone recently who reminded me of the work you do and thought you two should connect.", Messages: []string{"Would you be open to a quick intro?"}},

		// Feedback & advice
		{Theme: "feedback", Description: "I have been working on something and would really value a second opinion before moving forward.", Messages: []string{"Would you mind taking a quick look when you have a moment?", "I would appreciate your honest feedback on this."}},
		{Theme: "advice", Description: "I am facing a decision and I think your perspective could really help me think it through.", Messages: []string{"Have you dealt with anything similar before?", "What would you do in this situation?"}},

		// Planning & strategy
		{Theme: "planning", Description: "I am mapping out priorities for the next quarter and trying to stay realistic about what is achievable.", Messages: []string{"How do you decide what to focus on when everything feels urgent?", "What is your process for setting quarterly goals?"}},
		{Theme: "strategy", Description: "I have been rethinking how we allocate resources across projects and it is harder than it sounds.", Messages: []string{"How do you balance long-term bets with short-term wins?"}},

		// Reading & content
		{Theme: "reading", Description: "I just finished a great book that changed how I think about a few things at work.", Messages: []string{"Read anything good lately that stuck with you?", "Any books you keep recommending to people?"}},
		{Theme: "content", Description: "I have been curating a reading list and looking for suggestions outside my usual topics.", Messages: []string{"What is the most surprising thing you have read recently?"}},

		// Travel & experiences
		{Theme: "travel", Description: "I am starting to plan a trip and looking for recommendations from people who have been there.", Messages: []string{"Any travel tips or favorite destinations you would suggest?", "Where was the last place you traveled that exceeded expectations?"}},
		{Theme: "food", Description: "I tried a new restaurant last week that was genuinely impressive and thought you might enjoy it too.", Messages: []string{"Have you discovered any great spots lately?"}},

		// Wellness & balance
		{Theme: "wellness", Description: "I have been trying to be more intentional about work-life balance and curious how others handle it.", Messages: []string{"What do you do to recharge after a busy stretch?", "Have you found any habits that help you stay consistent?"}},
		{Theme: "fitness", Description: "I recently picked up a new workout routine and it has been making a real difference in my energy levels.", Messages: []string{"Do you have a go-to way to stay active during busy weeks?"}},

		// Events & community
		{Theme: "events", Description: "I saw a conference coming up that might be relevant and wanted to flag it for you.", Messages: []string{"Are you attending any events or meetups soon?", "What was the last event you went to that was actually worthwhile?"}},
		{Theme: "community", Description: "I have been getting more involved in a professional community and it has been a great source of ideas.", Messages: []string{"Are you part of any groups or communities you find valuable?"}},

		// Hiring & careers
		{Theme: "hiring", Description: "We have been expanding the team and I have been learning a lot about what makes a strong hire.", Messages: []string{"What do you look for when bringing someone new on board?"}},
		{Theme: "career", Description: "I have been reflecting on where I want to be in the next few years and it is a useful exercise.", Messages: []string{"How do you think about career growth without burning out?"}},

		// Gratitude & appreciation
		{Theme: "gratitude", Description: "I was thinking about the people who have been helpful to me this year and you came to mind.", Messages: []string{"Just wanted to say thanks for being a great connection.", "Appreciate you always being willing to share your perspective."}},
	}

	// Assign stable, content-derived ids so a given static conversation has the
	// same id on every send (see staticConvID).
	for i := range conversations {
		conversations[i].ID = staticConvID(conversations[i].Theme, conversations[i].Description)
	}

	return conversations
}

// directedWarmupPartner resolves a task's explicit reply-back target. Nil for
// an ordinary task, or when the target is no longer eligible, in which case the
// caller draws a partner as usual.
func (s *tasksService) directedWarmupPartner(ctx context.Context, taskID uuid.UUID, poolType string) *Email {
	warmupTask, err := s.taskRepo.GetWarmupTask(ctx, taskID)
	if err != nil || warmupTask == nil || warmupTask.TargetAccountID == nil {
		return nil
	}
	target := *warmupTask.TargetAccountID

	if s.warmupHealth != nil {
		if ok, _, herr := s.warmupHealth.CanParticipate(ctx, target, poolType); herr != nil || !ok {
			return nil
		}
	}
	partner, xerr := s.emailRepo.GetByID(ctx, target)
	if xerr != nil || partner == nil {
		return nil
	}
	return partner
}

// orgSuspendedOrRestricted reports whether the workspace's posture bars the
// paid warmup pool. Fails open.
func (s *tasksService) orgSuspendedOrRestricted(ctx context.Context, orgID uuid.UUID) bool {
	if s.orgRiskRepo == nil {
		return false
	}
	states, err := s.orgRiskRepo.GetOrgRiskStates(ctx, []uuid.UUID{orgID})
	if err != nil {
		return false
	}
	return states[orgID].ForcesFreeWarmupPool()
}

// orgBlocksSending reports whether the workspace is suspended. Fails open, for
// the same reason every other risk read does.
func (s *tasksService) orgBlocksSending(ctx context.Context, orgID *uuid.UUID) bool {
	if s.orgRiskRepo == nil || orgID == nil {
		return false
	}
	states, err := s.orgRiskRepo.GetOrgRiskStates(ctx, []uuid.UUID{*orgID})
	if err != nil {
		return false
	}
	return states[*orgID].BlocksSending()
}

// otherPoolType is the tier a thin pool borrows from.
func otherPoolType(poolType string) string {
	if poolType == "premium" {
		return "free"
	}
	return "premium"
}
