// Contract tests for plan-aware worker assignment.
//
// These lock the rule that's at the heart of multi-tenant deliverability:
// who lands on which worker is a function of the org's subscription, not of
// the request shape or who happens to be online. If this contract ever
// regresses, free-tier orgs could leak onto premium workers (or vice versa)
// and tank the IPs of paying customers.
//
// Test style: hand-rolled stub repos that embed the interface as a nil
// field so only the methods AssignWorkerToEmail actually touches need
// real bodies — anything else panics, which is the desired loud failure.

package worker

import (
	"context"
	"errors"
	"testing"

	"github.com/google/uuid"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/repository"
)

// stubs

type stubWorkerRepo struct {
	repository.WorkerRepository // embed for the panic-on-unused-method behavior

	dedicatedForOrg         *models.Worker
	sharedFree              []models.Worker
	sharedPremium           []models.Worker
	capacityFree            []repository.WorkerCapacityRowDB
	capacityPremium         []repository.WorkerCapacityRowDB
	workersByID             map[uuid.UUID]models.Worker
	placementHint           *repository.EmailAccountPlacementHint
	lastEmailWorkerAssigned uuid.UUID
	lastEmailPoolTypeSet    string
	incrementedWorkerCounts map[uuid.UUID]int
	loadScoreDeltas         map[uuid.UUID]float64

	// Dedicated auto-promotion knobs.
	availableDedicated     *models.Worker                  // GetAvailableDedicatedWorker result
	promotableDedicated    *models.Worker                  // PromoteIdlePremiumWorkerToDedicated result
	dedicatedAssignCreated bool                            // CreateDedicatedAssignmentIfNotExists result
	setWorkerTypeCalls     map[uuid.UUID]models.WorkerType // records SetWorkerType calls

	// Risk-band placement knobs.
	riskBand       models.EmailRiskBand                      // GetEmailAccountRiskBand result ("" → clean)
	sharedByPool   map[models.WorkerRiskPool][]models.Worker // GetSharedWorkersByTierAndPool result
	promotedToPool *models.Worker                            // PromoteWorkerToPool result
}

func (r *stubWorkerRepo) GetDedicatedWorkerByOrgID(_ context.Context, _ uuid.UUID) (*models.Worker, error) {
	return r.dedicatedForOrg, nil
}
func (r *stubWorkerRepo) GetSharedWorkersByTier(_ context.Context, freeTier bool) ([]models.Worker, error) {
	if freeTier {
		return r.sharedFree, nil
	}
	return r.sharedPremium, nil
}
func (r *stubWorkerRepo) UpdateEmailAccountWorker(_ context.Context, emailID, workerID uuid.UUID) error {
	r.lastEmailWorkerAssigned = workerID
	return nil
}
func (r *stubWorkerRepo) IncrementAccountCount(_ context.Context, workerID uuid.UUID) error {
	if r.incrementedWorkerCounts == nil {
		r.incrementedWorkerCounts = map[uuid.UUID]int{}
	}
	r.incrementedWorkerCounts[workerID]++
	return nil
}
func (r *stubWorkerRepo) UpdateEmailAccountWarmupPoolType(_ context.Context, _ uuid.UUID, pool string) error {
	r.lastEmailPoolTypeSet = pool
	return nil
}

// Capacity-aware methods. Default behaviour: empty capacity view so the
// assignment service falls back to the legacy account-count path. Tests
// that want to exercise the capacity-aware path populate
// capacityFree/capacityPremium + workersByID.
func (r *stubWorkerRepo) ListCapacityCandidates(_ context.Context, freeTier bool, _ []models.WorkerHealthState) ([]repository.WorkerCapacityRowDB, error) {
	if freeTier {
		return r.capacityFree, nil
	}
	return r.capacityPremium, nil
}

func (r *stubWorkerRepo) GetByID(_ context.Context, id uuid.UUID) (*models.Worker, error) {
	w, ok := r.workersByID[id]
	if !ok {
		return nil, nil
	}
	return &w, nil
}

func (r *stubWorkerRepo) GetEmailAccountPlacementHint(_ context.Context, _ uuid.UUID) (*repository.EmailAccountPlacementHint, error) {
	return r.placementHint, nil
}

func (r *stubWorkerRepo) AddLoadScore(_ context.Context, workerID uuid.UUID, delta float64) error {
	if r.loadScoreDeltas == nil {
		r.loadScoreDeltas = map[uuid.UUID]float64{}
	}
	r.loadScoreDeltas[workerID] += delta
	return nil
}

func (r *stubWorkerRepo) GetEmailAccountRiskBand(_ context.Context, _ uuid.UUID) (models.EmailRiskBand, error) {
	if r.riskBand == "" {
		return models.EmailRiskBandClean, nil
	}
	return r.riskBand, nil
}

func (r *stubWorkerRepo) GetAvailableDedicatedWorker(_ context.Context) (*models.Worker, error) {
	return r.availableDedicated, nil
}

func (r *stubWorkerRepo) PromoteIdlePremiumWorkerToDedicated(_ context.Context) (*models.Worker, error) {
	if r.promotableDedicated == nil {
		return nil, nil
	}
	w := *r.promotableDedicated
	w.WorkerType = models.WorkerTypeDedicated
	return &w, nil
}

func (r *stubWorkerRepo) CreateDedicatedAssignmentIfNotExists(_ context.Context, a *models.DedicatedWorkerAssignment) (bool, error) {
	if r.dedicatedAssignCreated {
		// Bind it so the post-assign re-fetch in AssignWorkerToEmail finds it.
		r.dedicatedForOrg = &models.Worker{ID: a.WorkerID, WorkerType: models.WorkerTypeDedicated, Active: true}
	}
	return r.dedicatedAssignCreated, nil
}

func (r *stubWorkerRepo) SetWorkerType(_ context.Context, id uuid.UUID, t models.WorkerType) error {
	if r.setWorkerTypeCalls == nil {
		r.setWorkerTypeCalls = map[uuid.UUID]models.WorkerType{}
	}
	r.setWorkerTypeCalls[id] = t
	return nil
}

func (r *stubWorkerRepo) GetSharedWorkersByTierAndPool(_ context.Context, _ bool, pool models.WorkerRiskPool) ([]models.Worker, error) {
	return r.sharedByPool[pool], nil
}

func (r *stubWorkerRepo) PromoteWorkerToPool(_ context.Context, _ bool, _ models.WorkerRiskPool) (*models.Worker, error) {
	return r.promotedToPool, nil
}

type stubSubRepo struct {
	repository.SubscriptionRepository
	sub *models.Subscription
}

func (r *stubSubRepo) GetByOrganizationID(_ context.Context, _ uuid.UUID) (*models.Subscription, error) {
	return r.sub, nil
}

type stubPlanRepo struct {
	repository.PlanRepository
	plan *models.Plan
}

func (r *stubPlanRepo) GetByID(_ context.Context, _ uuid.UUID) (*models.Plan, error) {
	return r.plan, nil
}

// tests

func newWorker(id uuid.UUID, freeTier bool, wtype models.WorkerType) models.Worker {
	return models.Worker{ID: id, FreeTier: freeTier, WorkerType: wtype, Active: true}
}

func TestAssign_FreeOrg_LandsOnFreeSharedWorker(t *testing.T) {
	// Tier separation only exists when billing does. With BILLING_PROVIDER=none
	// (the self-host default) there is no paid/free split to enforce — see
	// TestAssign_SelfHost_PlacesEveryOrgAsPaid.
	t.Setenv("BILLING_PROVIDER", "stripe")

	freeWorker := newWorker(uuid.New(), true, models.WorkerTypeShared)
	premiumWorker := newWorker(uuid.New(), false, models.WorkerTypeShared)

	wr := &stubWorkerRepo{
		sharedFree:    []models.Worker{freeWorker},
		sharedPremium: []models.Worker{premiumWorker},
	}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: nil}, &stubPlanRepo{})

	emailID := uuid.New()
	got, err := svc.AssignWorkerToEmail(context.Background(), emailID, uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != freeWorker.ID {
		t.Errorf("free org should land on free worker, got %s (free=%s)", got, freeWorker.ID)
	}
	if wr.lastEmailPoolTypeSet != "free" {
		t.Errorf("free org should join the free warmup pool, got %q", wr.lastEmailPoolTypeSet)
	}
}

func TestAssign_PaidOrg_LandsOnPremiumSharedWorker(t *testing.T) {
	freeWorker := newWorker(uuid.New(), true, models.WorkerTypeShared)
	premiumWorker := newWorker(uuid.New(), false, models.WorkerTypeShared)

	wr := &stubWorkerRepo{
		sharedFree:    []models.Worker{freeWorker},
		sharedPremium: []models.Worker{premiumWorker},
	}
	// Subscription is active but plan has no dedicated workers.
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 0}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != premiumWorker.ID {
		t.Errorf("paid org should land on premium worker, got %s (premium=%s)", got, premiumWorker.ID)
	}
	if wr.lastEmailPoolTypeSet != "premium" {
		t.Errorf("paid org should join the premium warmup pool, got %q", wr.lastEmailPoolTypeSet)
	}
}

func TestAssign_PaidOrgWithDedicatedPlan_LandsOnDedicatedWorker(t *testing.T) {
	dedicated := newWorker(uuid.New(), false, models.WorkerTypeDedicated)
	premium := newWorker(uuid.New(), false, models.WorkerTypeShared)

	wr := &stubWorkerRepo{
		dedicatedForOrg: &dedicated,
		sharedPremium:   []models.Worker{premium},
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 1}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != dedicated.ID {
		t.Errorf("paid org with dedicated plan + assignment should land on the dedicated worker, got %s", got)
	}
}

func TestAssign_PaidOrgWithDedicatedPlanButNoAssignment_FallsBackToPremium(t *testing.T) {
	// Dedicated plan, no bound worker, no free dedicated worker, AND nothing
	// to promote (no idle premium spare). The add must still succeed by
	// falling back to a premium shared worker rather than failing.
	premium := newWorker(uuid.New(), false, models.WorkerTypeShared)
	wr := &stubWorkerRepo{
		dedicatedForOrg:     nil, // org has the plan but no worker assigned yet
		availableDedicated:  nil, // dedicated pool is empty
		promotableDedicated: nil, // and there's no idle premium spare to promote
		sharedPremium:       []models.Worker{premium},
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 1}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != premium.ID {
		t.Errorf("org with dedicated plan but nothing to promote should fall back to premium, got %s", got)
	}
}

func TestAssign_PaidOrgWithDedicatedPlanButNoAssignment_PromotesSpareWorker(t *testing.T) {
	// Dedicated plan, no bound worker, dedicated pool empty — but an idle
	// premium shared worker is available to promote. The mailbox must land on
	// the promoted (now dedicated) worker, not on the shared pool.
	spare := newWorker(uuid.New(), false, models.WorkerTypeShared)
	wr := &stubWorkerRepo{
		dedicatedForOrg:        nil,
		availableDedicated:     nil,
		promotableDedicated:    &spare,
		dedicatedAssignCreated: true, // bind succeeds
		sharedPremium:          []models.Worker{newWorker(uuid.New(), false, models.WorkerTypeShared)},
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 1}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != spare.ID {
		t.Errorf("org with dedicated plan should land on the promoted spare worker %s, got %s", spare.ID, got)
	}
}

func TestAssignDedicatedWorker_PromoteThenLoseBind_RevertsToShared(t *testing.T) {
	// Promotion succeeds but the bind race is lost (a concurrent add bound the
	// org first). The just-promoted worker must be reverted to shared so it
	// isn't stranded as an unbound dedicated box.
	spare := newWorker(uuid.New(), false, models.WorkerTypeShared)
	wr := &stubWorkerRepo{
		availableDedicated:     nil, // dedicated pool empty → promote
		promotableDedicated:    &spare,
		dedicatedAssignCreated: false, // lose the bind race
	}
	svc := NewAssignmentService(wr, &stubSubRepo{}, &stubPlanRepo{})

	err := svc.AssignDedicatedWorker(context.Background(), uuid.New(), uuid.New())
	if !errors.Is(err, ErrOrgAlreadyAssigned) {
		t.Fatalf("expected ErrOrgAlreadyAssigned on lost bind race, got %v", err)
	}
	if got, ok := wr.setWorkerTypeCalls[spare.ID]; !ok || got != models.WorkerTypeShared {
		t.Errorf("promoted worker must be reverted to shared after losing the bind race, got %v (called=%v)", got, ok)
	}
}

func TestAssign_RiskyMailbox_LandsOnRiskyPoolWorker(t *testing.T) {
	// A risky mailbox must land on a worker in the risky pool, never on a
	// clean-pool worker, so it can't damage the reputation of trusted inboxes.
	riskyWorker := newWorker(uuid.New(), false, models.WorkerTypeShared)
	riskyWorker.RiskPool = models.WorkerRiskPoolRisky
	cleanWorker := newWorker(uuid.New(), false, models.WorkerTypeShared)

	wr := &stubWorkerRepo{
		riskBand: models.EmailRiskBandRisky,
		sharedByPool: map[models.WorkerRiskPool][]models.Worker{
			models.WorkerRiskPoolRisky: {riskyWorker},
			models.WorkerRiskPoolClean: {cleanWorker},
		},
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 0}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != riskyWorker.ID {
		t.Errorf("risky mailbox must land on a risky-pool worker %s, got %s", riskyWorker.ID, got)
	}
}

func TestAssign_RiskyMailbox_NoRiskyWorker_PromotesCleanWorker(t *testing.T) {
	// When no risky-pool worker exists, we promote an idle clean worker into
	// the risky pool rather than co-locating with trusted inboxes.
	promoted := newWorker(uuid.New(), false, models.WorkerTypeShared)
	promoted.RiskPool = models.WorkerRiskPoolRisky

	wr := &stubWorkerRepo{
		riskBand:       models.EmailRiskBandRisky,
		sharedByPool:   map[models.WorkerRiskPool][]models.Worker{}, // risky pool empty
		promotedToPool: &promoted,
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 0}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != promoted.ID {
		t.Errorf("risky mailbox with empty pool should land on the promoted worker %s, got %s", promoted.ID, got)
	}
}

func TestAssign_RiskyMailbox_NoWorkerNoPromotion_Refuses(t *testing.T) {
	// Strict invariant: if there's no risky-pool worker and nothing to
	// promote, refuse rather than place a risky inbox next to trusted ones.
	wr := &stubWorkerRepo{
		riskBand:       models.EmailRiskBandQuarantine,
		sharedByPool:   map[models.WorkerRiskPool][]models.Worker{},
		promotedToPool: nil,
	}
	sub := paidSub()
	plan := &models.Plan{ID: sub.PlanID, DedicatedWorkers: 0}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: sub}, &stubPlanRepo{plan: plan})

	_, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if !errors.Is(err, ErrNoAvailableWorkers) {
		t.Errorf("strict placement should refuse with ErrNoAvailableWorkers, got %v", err)
	}
}

func TestSelectSharedWorker_NoWorkers_Errors(t *testing.T) {
	svc := NewAssignmentService(&stubWorkerRepo{}, &stubSubRepo{}, &stubPlanRepo{})
	if _, err := svc.SelectSharedWorker(context.Background(), true); err != ErrNoAvailableWorkers {
		t.Fatalf("expected ErrNoAvailableWorkers, got %v", err)
	}
}

// paidSub returns a minimal subscription that HasPaidSubscription() will
// return true for: status == "active" AND StripeSubscriptionID set.
func paidSub() *models.Subscription {
	sid := "sub_test_" + uuid.NewString()
	return &models.Subscription{
		ID:                   uuid.New(),
		PlanID:               uuid.New(),
		Status:               models.SubscriptionStatusActive,
		StripeSubscriptionID: &sid,
	}
}

// capacity-aware selection tests
//
// These exercise the new path: ListCapacityCandidates returns rows,
// the service computes utilization, sorts ASC, and lands the mailbox on
// the least-utilised worker that still has headroom for the weight.

func capacityRow(id uuid.UUID, base, load float64) repository.WorkerCapacityRowDB {
	return repository.WorkerCapacityRowDB{
		WorkerID:         id,
		WorkerType:       models.WorkerTypeShared,
		FreeTier:         true,
		EgressKind:       models.WorkerEgressColdSMTP,
		HealthState:      models.WorkerHealthHealthy,
		LoadScore:        load,
		BaseCapacity:     base,
		HealthMultiplier: 1.0,
		AgeMultiplier:    1.0,
	}
}

func TestSelectSharedWorker_CapacityAware_LeastUtilizedWins(t *testing.T) {
	hot := uuid.New()  // 14/16 utilised
	cold := uuid.New() // 2/16 utilised

	wr := &stubWorkerRepo{
		capacityFree: []repository.WorkerCapacityRowDB{
			capacityRow(hot, 16, 14),
			capacityRow(cold, 16, 2),
		},
		workersByID: map[uuid.UUID]models.Worker{
			hot:  {ID: hot, FreeTier: true, WorkerType: models.WorkerTypeShared, Active: true},
			cold: {ID: cold, FreeTier: true, WorkerType: models.WorkerTypeShared, Active: true},
		},
	}
	svc := NewAssignmentService(wr, &stubSubRepo{}, &stubPlanRepo{})
	got, err := svc.SelectSharedWorker(context.Background(), true)
	if err != nil {
		t.Fatalf("SelectSharedWorker: %v", err)
	}
	if got.ID != cold {
		t.Errorf("least-utilized should win: got %s, want %s", got.ID, cold)
	}
}

func TestSelectSharedWorker_CapacityAware_FiltersOutSaturated(t *testing.T) {
	// Saturated worker (load == base) has zero headroom. Filtered out;
	// the only remaining candidate wins.
	saturated := uuid.New()
	headroom := uuid.New()

	wr := &stubWorkerRepo{
		capacityFree: []repository.WorkerCapacityRowDB{
			capacityRow(saturated, 16, 16),
			capacityRow(headroom, 16, 4),
		},
		workersByID: map[uuid.UUID]models.Worker{
			saturated: {ID: saturated, FreeTier: true, WorkerType: models.WorkerTypeShared, Active: true},
			headroom:  {ID: headroom, FreeTier: true, WorkerType: models.WorkerTypeShared, Active: true},
		},
	}
	svc := NewAssignmentService(wr, &stubSubRepo{}, &stubPlanRepo{})
	got, err := svc.SelectSharedWorker(context.Background(), true)
	if err != nil {
		t.Fatalf("SelectSharedWorker: %v", err)
	}
	if got.ID != headroom {
		t.Errorf("saturated worker should be filtered, got %s, want %s", got.ID, headroom)
	}
}

func TestSelectSharedWorker_CapacityAware_FallsBackWhenAllSaturated(t *testing.T) {
	// Every worker is full; the legacy account_count path catches us so
	// we don't fail the assignment outright. Falls back through
	// selectSharedWorkerLegacy -> GetSharedWorkersByTier.
	a := newWorker(uuid.New(), true, models.WorkerTypeShared)
	wr := &stubWorkerRepo{
		capacityFree: []repository.WorkerCapacityRowDB{
			capacityRow(a.ID, 16, 16),
		},
		workersByID: map[uuid.UUID]models.Worker{a.ID: a},
		sharedFree:  []models.Worker{a},
	}
	svc := NewAssignmentService(wr, &stubSubRepo{}, &stubPlanRepo{})
	got, err := svc.SelectSharedWorker(context.Background(), true)
	if err != nil {
		t.Fatalf("SelectSharedWorker: %v", err)
	}
	if got.ID != a.ID {
		t.Errorf("fallback should still return the only worker, got %s", got.ID)
	}
}

// With billing disabled every org places as paid. HasPaidSubscription requires a
// Stripe subscription id, so without this a self-host org is free tier forever
// and can only ever be placed onto free-tier workers — while a stock worker
// registers as premium, leaving no candidate and no way to connect a mailbox.
func TestAssign_SelfHost_PlacesEveryOrgAsPaid(t *testing.T) {
	t.Setenv("BILLING_PROVIDER", "none")

	premiumWorker := newWorker(uuid.New(), false, models.WorkerTypeShared)
	wr := &stubWorkerRepo{sharedPremium: []models.Worker{premiumWorker}}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: nil}, &stubPlanRepo{})

	got, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New())
	if err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if *got != premiumWorker.ID {
		t.Errorf("self-host org should place on the premium worker, got %s", got)
	}
	if wr.lastEmailPoolTypeSet != "premium" {
		t.Errorf("self-host org should join the premium warmup pool, got %q", wr.lastEmailPoolTypeSet)
	}
}

func TestAssign_UpdatesLoadScoreByMailboxWeight(t *testing.T) {
	// AssignWorkerToEmail must bump load_score by MailboxWeight. With an
	// OAuth provider the bump is 0.05; with cold SMTP it's 1.0; with
	// warmup it's 0.4.
	t.Setenv("BILLING_PROVIDER", "stripe")

	freeWorker := newWorker(uuid.New(), true, models.WorkerTypeShared)
	wr := &stubWorkerRepo{
		sharedFree:    []models.Worker{freeWorker},
		placementHint: &repository.EmailAccountPlacementHint{Provider: "gmail-api", IsWarmup: false},
	}
	svc := NewAssignmentService(wr, &stubSubRepo{sub: nil}, &stubPlanRepo{})

	if _, err := svc.AssignWorkerToEmail(context.Background(), uuid.New(), uuid.New()); err != nil {
		t.Fatalf("AssignWorkerToEmail: %v", err)
	}
	if got := wr.loadScoreDeltas[freeWorker.ID]; got != 0.05 {
		t.Errorf("load_score should be bumped by 0.05 for gmail-api, got %v", got)
	}
}
