// Package compose picks the sending mailbox for a brand-new outbound email.
// The scoring is deliberately explainable: an existing conversation with the
// recipient dominates (contacts should keep hearing from the same mailbox),
// then remaining daily budget, then domain-auth health. Every candidate
// carries human-readable reasons so the picker can show why, not just what.
package compose

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"time"

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

type Candidate struct {
	Account       models.Email
	SentToday     int
	DailyLimit    int
	History       int
	LastContactAt *time.Time
	Score         int
	Reasons       []string
	Recommended   bool
}

// Remaining is today's unspent send budget, floored at zero.
func (c *Candidate) Remaining() int {
	if r := c.DailyLimit - c.SentToday; r > 0 {
		return r
	}
	return 0
}

type Service interface {
	// Candidates returns the user's active mailboxes scored against the
	// recipient address, sorted best-first, with Recommended set on the
	// mailbox auto-send would use. Address may be empty (no affinity signal).
	Candidates(ctx context.Context, userID, orgID uuid.UUID, address string) ([]Candidate, *errx.Error)
	// Resolve returns the mailbox a compose send should use: the explicit
	// accountID when given (validated against the user's active mailboxes),
	// otherwise the best-scored candidate. A non-nil tagID scopes the
	// automatic pick to mailboxes carrying that tag ("Auto within a tag");
	// it is ignored when accountID is explicit. The bool reports whether
	// the pick was automatic.
	Resolve(ctx context.Context, userID, orgID uuid.UUID, accountID, tagID *uuid.UUID, address string) (*Candidate, bool, *errx.Error)

	// Drafts: autosaved per-user working copies from the compose window.
	UpsertDraft(ctx context.Context, userID, orgID uuid.UUID, d *repository.ComposeDraft) *errx.Error
	ListDrafts(ctx context.Context, userID, orgID uuid.UUID) ([]repository.ComposeDraft, *errx.Error)
	DeleteDraft(ctx context.Context, userID, id uuid.UUID) *errx.Error
}

type service struct {
	emailRepo   repository.EmailRepository
	composeRepo repository.ComposeRepository
}

func NewService(emailRepo repository.EmailRepository, composeRepo repository.ComposeRepository) Service {
	return &service{emailRepo: emailRepo, composeRepo: composeRepo}
}

func (s *service) Candidates(ctx context.Context, userID, orgID uuid.UUID, address string) ([]Candidate, *errx.Error) {
	// Scoped to the caller's current organization, not to the caller: a member of
	// two workspaces must not compose from the other workspace's mailboxes.
	accounts, xerr := s.emailRepo.GetAllActiveInScope(ctx, repository.NewAccountScope(&orgID))
	if xerr != nil {
		return nil, xerr
	}
	if len(accounts) == 0 {
		return []Candidate{}, nil
	}

	ids := make([]uuid.UUID, len(accounts))
	for i, a := range accounts {
		ids[i] = a.ID
	}

	sent, err := s.composeRepo.TodaySentCounts(ctx, ids)
	if err != nil {
		return nil, errx.InternalError()
	}

	history := map[uuid.UUID]repository.ComposeAffinity{}
	address = strings.TrimSpace(address)
	if address != "" {
		history, err = s.composeRepo.AddressHistoryByAccount(ctx, orgID, address)
		if err != nil {
			return nil, errx.InternalError()
		}
	}

	candidates := make([]Candidate, 0, len(accounts))
	for _, a := range accounts {
		c := Candidate{
			Account:    a,
			SentToday:  sent[a.ID],
			DailyLimit: a.CampaignLimit,
		}
		if aff, ok := history[a.ID]; ok {
			c.History = aff.Messages
			c.LastContactAt = aff.LastAt
		}
		score(&c)
		candidates = append(candidates, c)
	}

	sort.SliceStable(candidates, func(i, j int) bool {
		return candidates[i].Score > candidates[j].Score
	})

	// Recommend the best mailbox that still has budget today; if every
	// mailbox is spent, the top-scored one is still marked so the UI has
	// a default (the send itself queues for tomorrow's window anyway).
	for i := range candidates {
		if candidates[i].Remaining() > 0 {
			candidates[i].Recommended = true
			break
		}
	}
	if len(candidates) > 0 && !hasRecommended(candidates) {
		candidates[0].Recommended = true
	}

	return candidates, nil
}

func (s *service) Resolve(ctx context.Context, userID, orgID uuid.UUID, accountID, tagID *uuid.UUID, address string) (*Candidate, bool, *errx.Error) {
	candidates, xerr := s.Candidates(ctx, userID, orgID, address)
	if xerr != nil {
		return nil, false, xerr
	}
	if len(candidates) == 0 {
		return nil, false, errx.New(errx.BadRequest, "no active mailboxes to send from; connect a mailbox first")
	}

	if accountID != nil {
		for i := range candidates {
			if candidates[i].Account.ID == *accountID {
				return &candidates[i], false, nil
			}
		}
		return nil, false, errx.New(errx.NotFound, "mailbox not found or not active")
	}

	// Tag-scoped auto: restrict the pool to mailboxes carrying the tag,
	// then apply the same best-with-budget-first rule within it. GetByTags
	// is scoped to the same organization, so a foreign tag id just yields no
	// members.
	if tagID != nil {
		members, merr := s.emailRepo.GetByTags(ctx, repository.NewAccountScope(&orgID), []string{tagID.String()})
		if merr != nil {
			return nil, false, merr
		}
		in := make(map[uuid.UUID]bool, len(members))
		for _, m := range members {
			in[m.ID] = true
		}
		scoped := candidates[:0:0]
		for i := range candidates {
			if in[candidates[i].Account.ID] {
				scoped = append(scoped, candidates[i])
			}
		}
		if len(scoped) == 0 {
			return nil, false, errx.New(errx.BadRequest, "no active mailbox carries the selected tag")
		}
		for i := range scoped {
			if scoped[i].Remaining() > 0 {
				return &scoped[i], true, nil
			}
		}
		return &scoped[0], true, nil
	}

	for i := range candidates {
		if candidates[i].Recommended {
			return &candidates[i], true, nil
		}
	}
	return &candidates[0], true, nil
}

func (s *service) UpsertDraft(ctx context.Context, userID, orgID uuid.UUID, d *repository.ComposeDraft) *errx.Error {
	if err := s.composeRepo.UpsertDraft(ctx, userID, orgID, d); err != nil {
		return errx.InternalError()
	}
	return nil
}

func (s *service) ListDrafts(ctx context.Context, userID, orgID uuid.UUID) ([]repository.ComposeDraft, *errx.Error) {
	drafts, err := s.composeRepo.ListDrafts(ctx, userID, orgID)
	if err != nil {
		return nil, errx.InternalError()
	}
	return drafts, nil
}

func (s *service) DeleteDraft(ctx context.Context, userID, id uuid.UUID) *errx.Error {
	if err := s.composeRepo.DeleteDraft(ctx, userID, id); err != nil {
		return errx.InternalError()
	}
	return nil
}

// score fills Score + Reasons. Affinity dominates (+1000 band), budget and
// auth health order the rest; a spent daily budget pushes the mailbox below
// every fresh one without hiding it from the picker.
func score(c *Candidate) {
	remaining := c.Remaining()
	c.Score = remaining

	if c.History > 0 {
		bonus := c.History
		if bonus > 50 {
			bonus = 50
		}
		c.Score += 1000 + bonus*10
		c.Reasons = append(c.Reasons, fmt.Sprintf("%d past messages with this contact", c.History))
		if c.LastContactAt != nil && time.Since(*c.LastContactAt) < 30*24*time.Hour {
			c.Score += 50
		}
	}

	switch c.Account.AuthState {
	case "passing":
		c.Score += 25
		c.Reasons = append(c.Reasons, "domain authenticated")
	case "failing":
		c.Score -= 200
		c.Reasons = append(c.Reasons, "domain auth failing")
	}

	if remaining == 0 {
		c.Score -= 5000
		c.Reasons = append(c.Reasons, "daily limit reached")
	} else {
		c.Reasons = append(c.Reasons, fmt.Sprintf("%d of %d sends left today", remaining, c.DailyLimit))
	}
}

func hasRecommended(cs []Candidate) bool {
	for i := range cs {
		if cs[i].Recommended {
			return true
		}
	}
	return false
}

// RecommendedReason is the one-line explanation shown next to the Auto pick.
func RecommendedReason(c *Candidate) string {
	if c == nil {
		return ""
	}
	if c.History > 0 {
		return "already in conversation with this contact"
	}
	if c.Remaining() > 0 {
		return "most sending budget left today"
	}
	return "least loaded mailbox (all daily budgets spent)"
}
