package wmail

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/rs/zerolog/log"
	"github.com/warmbly/warmbly/internal/client/goog"
	"github.com/warmbly/warmbly/internal/client/msgraph"
	"github.com/warmbly/warmbly/internal/client/smtpimap/imap"
	"github.com/warmbly/warmbly/internal/client/smtpimap/smtp"
	"github.com/warmbly/warmbly/internal/config"
	"github.com/warmbly/warmbly/internal/errx"
	"github.com/warmbly/warmbly/internal/models"
)

// parentReference resolves what a reply should be threaded onto, from whichever
// of the two independent handles the request carries.
//
// They are genuinely independent. ThreadID is the provider's own conversation
// id and is what makes the message appear in the thread in the SENDER's
// mailbox; it is meaningless to anyone else. MessageID becomes the RFC
// In-Reply-To/References headers, which is the only thing the RECIPIENT's mail
// client can thread on. A dashboard reply starts with only a ThreadID and a
// warmup reply with only a Message-ID, so requiring both threads neither.
func parentReference(req *SendRequest) *models.EmailMessageData {
	if req == nil {
		return nil
	}
	var messageID, threadID string
	if req.Parent != nil {
		messageID = req.Parent.MessageID
		threadID = req.Parent.ThreadID
	}
	if messageID == "" && req.InReplyTo != "" {
		messageID = strings.Trim(req.InReplyTo, "<>")
	}
	if messageID == "" && threadID == "" {
		return nil
	}
	return &models.EmailMessageData{MessageID: messageID, ThreadID: threadID}
}

// Attachment is a fully-resolved attachment ready to be MIME-encoded: the
// worker has already fetched Data from object storage.
type Attachment struct {
	Filename string
	MimeType string
	Data     []byte
}

// SendRequest contains all parameters needed to send an email
type SendRequest struct {
	TaskID      uuid.UUID
	To          []string
	Cc          []string
	Bcc         []string
	MessageID   string
	Subject     string
	BodyPlain   string
	BodyHTML    string
	InReplyTo   string
	Parent      *models.EmailParent
	IsWarmup    bool
	WarmupToken string
	// UnsubscribeURL, when set (campaign sends with the unsubscribe header
	// enabled), produces RFC 8058 one-click unsubscribe headers.
	UnsubscribeURL string
	// Attachments, when present, are encoded as multipart/mixed parts after the
	// multipart/alternative text body. Warmup sends never carry attachments.
	Attachments []Attachment
	// FromName is the display name the control plane holds for the mailbox at
	// publish time. Empty falls back to the name cached from ADD_EMAIL.
	FromName string
}

// buildSendHeaders assembles the outbound custom headers: the warmup
// verification token (warmup sends) and RFC 8058 one-click unsubscribe headers
// (campaign sends). Returns nil when there are none so callers can branch.
func buildSendHeaders(req *SendRequest) map[string]string {
	h := map[string]string{}
	if req.WarmupToken != "" {
		h[config.WarmupVerifyHeader] = req.WarmupToken
	}
	if req.UnsubscribeURL != "" {
		// RFC 8058: the HTTPS URI in List-Unsubscribe plus the one-click marker
		// tells Gmail/Yahoo/Microsoft to POST List-Unsubscribe=One-Click here.
		h["List-Unsubscribe"] = "<" + req.UnsubscribeURL + ">"
		h["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
	}
	if len(h) == 0 {
		return nil
	}
	return h
}

// SendResult contains the result of a send operation
type SendResult struct {
	Success       bool
	MessageID     string
	ProviderMsgID string
	SentAt        time.Time
	Error         *errx.MailError
}

const maxSendRetries = 3

// Send attempts to send an email with retry for transient failures
func (w *WMail) Send(ctx context.Context, req *SendRequest) *SendResult {
	// For warmup emails, ensure HTML is empty
	bodyHTML := req.BodyHTML
	if req.IsWarmup {
		bodyHTML = ""
	}

	var result *SendResult
	for attempt := 0; attempt <= maxSendRetries; attempt++ {
		result = &SendResult{Success: false, SentAt: time.Now()}

		switch w.EmailType {
		case models.InboxProviderGoogle:
			result = w.sendViaGmail(ctx, req, bodyHTML)
		case models.InboxProviderOutlook:
			result = w.sendViaGraph(ctx, req, bodyHTML)
		case models.InboxProviderSMTPIMAP:
			result = w.sendViaSMTP(ctx, req, bodyHTML)
		default:
			result.Error = errx.MError(
				errx.MailErrorCritical,
				errx.MailErrorCodeUnsupported,
				"Unsupported email provider",
				errx.MailErrorResolveMethodNone,
			)
			return result
		}

		if result.Success {
			return result
		}

		// Don't retry critical/auth errors - only transient ones
		if result.Error != nil && result.Error.Type == errx.MailErrorCritical {
			return result
		}

		if attempt < maxSendRetries {
			backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s
			select {
			case <-ctx.Done():
				return result
			case <-time.After(backoff):
			}
		}
	}

	return result
}

// sendViaGmail sends an email using the Gmail API
func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML string) *SendResult {
	result := &SendResult{
		Success: false,
		SentAt:  time.Now(),
	}

	if w.GoogleData == nil || w.GoogleData.Client == nil {
		result.Error = errx.MError(
			errx.MailErrorCritical,
			errx.MailErrorCodeAuthenticationFailed,
			"Gmail client not initialized",
			errx.MailErrorResolveMethodReload,
		)
		return result
	}

	// Build parent reference for replies. Gating this on InReplyTo dropped a
	// perfectly good ThreadID whenever the header was absent, which is every
	// dashboard reply, so the provider started a new conversation instead.
	// Warmup replies are the mirror case: an RFC Message-ID from the token flow
	// and no local thread record.
	parent := parentReference(req)

	// Build custom headers (warmup token + RFC 8058 one-click unsubscribe).
	customHeaders := buildSendHeaders(req)

	// Convert resolved attachments to the goog transport shape (warmup sends
	// carry none, but threading req.Attachments is harmless when empty).
	attachments := toGoogAttachments(req.Attachments)

	// Send via Gmail API
	gmailMsg, err := w.GoogleData.Client.SendMessage(
		ctx,
		req.FromName,
		req.To,
		req.Cc,
		req.Bcc,
		req.MessageID,
		req.Subject,
		req.BodyPlain,
		bodyHTML,
		parent,
		attachments,
		customHeaders,
	)
	if err != nil {
		// Convert to MailError using goog.HandleError
		if mailErr := goog.HandleError(err); mailErr != nil {
			result.Error = mailErr
		} else {
			// Generic error
			result.Error = errx.MError(
				errx.MailErrorWarning,
				errx.MailErrorCodeServerUnreachable,
				err.Error(),
				errx.MailErrorResolveMethodRetry,
			)
		}
		return result
	}

	result.Success = true
	result.MessageID = req.MessageID
	result.ProviderMsgID = gmailMsg.Id
	return result
}

// sendViaGraph sends an email through Microsoft Graph. Graph re-stamps the
// Message-ID we mint, so the client creates the message as a draft, reads the
// id Exchange assigned, and sends that; the result carries that id rather than
// ours so warmup verification and campaign reply threading match what the
// recipient actually received.
func (w *WMail) sendViaGraph(ctx context.Context, req *SendRequest, bodyHTML string) *SendResult {
	result := &SendResult{
		Success: false,
		SentAt:  time.Now(),
	}

	if w.GraphData == nil || w.GraphData.Client == nil {
		result.Error = errx.MError(
			errx.MailErrorCritical,
			errx.MailErrorCodeAuthenticationFailed,
			"Microsoft Graph client not initialized",
			errx.MailErrorResolveMethodReload,
		)
		return result
	}

	// Build parent reference for replies. Gating this on InReplyTo dropped a
	// perfectly good ThreadID whenever the header was absent, which is every
	// dashboard reply, so the provider started a new conversation instead.
	// Warmup replies are the mirror case: an RFC Message-ID from the token flow
	// and no local thread record.
	parent := parentReference(req)

	// Warmup token + RFC 8058 one-click unsubscribe headers. RAW MIME is the
	// only Graph shape that can carry them, though Exchange still drops
	// custom headers in transit, which is why warmup verification does not
	// depend on the token header arriving.
	customHeaders := buildSendHeaders(req)
	attachments := toGraphAttachments(req.Attachments)

	sentMessageID, err := w.GraphData.Client.SendMessage(
		ctx,
		req.FromName,
		req.To,
		req.Cc,
		req.Bcc,
		req.MessageID,
		req.Subject,
		req.BodyPlain,
		bodyHTML,
		parent,
		attachments,
		customHeaders,
	)
	if err != nil {
		var mailErr *errx.MailError
		if errors.As(err, &mailErr) {
			result.Error = mailErr
		} else {
			result.Error = errx.MError(
				errx.MailErrorWarning,
				errx.MailErrorCodeServerUnreachable,
				err.Error(),
				errx.MailErrorResolveMethodRetry,
			)
		}
		return result
	}

	// Report the id Exchange stamped, not the one we minted: it is the only
	// value that matches what the recipient received.
	if sentMessageID == "" {
		sentMessageID = req.MessageID
	}
	result.Success = true
	result.MessageID = sentMessageID
	result.ProviderMsgID = sentMessageID // Graph send returns no separate provider id
	return result
}

// sendViaSMTP sends an email using SMTP
func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML string) *SendResult {
	result := &SendResult{
		Success: false,
		SentAt:  time.Now(),
	}

	if w.SmtpImapData == nil || w.SmtpImapData.SmtpClient == nil {
		result.Error = errx.MError(
			errx.MailErrorCritical,
			errx.MailErrorCodeAuthenticationFailed,
			"SMTP client not initialized",
			errx.MailErrorResolveMethodReload,
		)
		return result
	}

	// Build custom headers (warmup token + RFC 8058 one-click unsubscribe).
	smtpCustomHeaders := buildSendHeaders(req)

	// Convert resolved attachments to the SMTP transport shape.
	smtpAttachments := toSMTPAttachments(req.Attachments)

	// Send via SMTP. Attachments are passed explicitly (not variadic) so an
	// empty list still selects the same code path.
	raw, merr := w.SmtpImapData.SmtpClient.Send(
		ctx,
		req.FromName,
		req.To,
		req.Cc,
		req.Bcc,
		req.MessageID,
		req.Subject,
		req.BodyPlain,
		bodyHTML,
		req.InReplyTo,
		smtpAttachments,
		smtpCustomHeaders,
	)
	if merr != nil {
		result.Error = merr
		return result
	}

	// Warmup traffic is deliberately excluded: it is machine-generated volume,
	// and filing dozens a day would bury the customer's real sent mail.
	if !req.IsWarmup {
		w.saveSentCopy(ctx, raw, result.SentAt)
	}

	result.Success = true
	result.MessageID = req.MessageID
	result.ProviderMsgID = req.MessageID // SMTP uses the same message ID
	return result
}

// saveSentCopy files the message in the mailbox's Sent folder. SMTP submission
// leaves nothing behind in the sender's account, so without this the message
// is invisible to both the customer's own mail client and the unibox, whose
// thread reader can only show what the sync found in a folder.
//
// Best effort by design: the mail is already delivered, so a failure here is
// logged and never turns a successful send into a failed one.
func (w *WMail) saveSentCopy(ctx context.Context, raw []byte, sentAt time.Time) {
	if !w.SaveToSent || len(raw) == 0 {
		return
	}
	if w.SmtpImapData == nil || w.SmtpImapData.ImapClient == nil {
		return
	}
	err := w.SmtpImapData.ImapClient.AppendToSent(ctx, raw, sentAt)
	switch {
	case err == nil:
	case errors.Is(err, imap.ErrNoSentMailbox):
		log.Debug().Str("email_id", w.ID.String()).Msg("no sent folder on this account; skipping the sent copy")
	default:
		log.Warn().Err(err).Str("email_id", w.ID.String()).Msg("could not file a copy in the sent folder")
	}
}

// toGoogAttachments maps wmail attachments to the Gmail transport shape.
func toGoogAttachments(in []Attachment) []goog.Attachment {
	if len(in) == 0 {
		return nil
	}
	out := make([]goog.Attachment, 0, len(in))
	for _, a := range in {
		out = append(out, goog.Attachment{
			Filename: a.Filename,
			MimeType: a.MimeType,
			Data:     a.Data,
		})
	}
	return out
}

// toGraphAttachments maps wmail attachments to the Graph transport shape.
func toGraphAttachments(in []Attachment) []msgraph.Attachment {
	if len(in) == 0 {
		return nil
	}
	out := make([]msgraph.Attachment, 0, len(in))
	for _, a := range in {
		out = append(out, msgraph.Attachment{
			Filename: a.Filename,
			MimeType: a.MimeType,
			Data:     a.Data,
		})
	}
	return out
}

// toSMTPAttachments maps wmail attachments to the SMTP transport shape.
func toSMTPAttachments(in []Attachment) []smtp.Attachment {
	if len(in) == 0 {
		return nil
	}
	out := make([]smtp.Attachment, 0, len(in))
	for _, a := range in {
		out = append(out, smtp.Attachment{
			Filename: a.Filename,
			MimeType: a.MimeType,
			Data:     a.Data,
		})
	}
	return out
}

// DetermineErrorEventType maps a MailError to the appropriate JobEventType
func DetermineErrorEventType(err *errx.MailError) models.JobEventType {
	if err == nil {
		return models.JobEventTypeEmailFailed
	}

	switch err.Code {
	case errx.MailErrorCodeGoogleAuth, errx.MailErrorCodeAuthenticationFailed, errx.MailErrorCodeInvalidCredentials:
		return models.JobEventTypeEmailAuthError

	case errx.MailErrorCodeAccountSuspended, errx.MailErrorCodeAuthorizationFailed:
		return models.JobEventTypeEmailDisabled

	case errx.MailErrorCodeRateLimitExceeded, errx.MailErrorCodeSendingTooFast, errx.MailErrorCodeQuotaExceeded:
		return models.JobEventTypeEmailRateLimited

	case errx.MailErrorCodeServerUnreachable, errx.MailErrorCodeConnectionLost, errx.MailErrorCodeNotFound:
		return models.JobEventTypeEmailServerError

	default:
		return models.JobEventTypeEmailFailed
	}
}

// MailErrorToSendError converts a MailError to an EmailSendError for transport
func MailErrorToSendError(err *errx.MailError) *models.EmailSendError {
	if err == nil {
		return nil
	}

	userInfo := err.GetUserErrorInfo()

	return &models.EmailSendError{
		Code:           string(err.Code),
		Type:           string(err.Type),
		Message:        err.Message,
		ResolveMethod:  string(err.ResolveMethod),
		UserVisible:    err.IsUserVisible(),
		UserTitle:      userInfo.Title,
		UserMessage:    userInfo.Message,
		ActionRequired: userInfo.ActionRequired,
	}
}
