package protocol

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"sort"
	"strings"
	"time"

	"github.com/emersion/go-imap"
	imapclient "github.com/emersion/go-imap/client"

	"github.com/tidbcloud/AgentTeam/internal/observability/logging"
)

const (
	defaultSearchLimit  = 20
	maxSearchResults    = 50
	maxSearchCandidates = 100
	maxSearchTextBytes  = 2048
	maxSearchAddresses  = 10
)

func (client *IMAPClient) Search(
	ctx context.Context,
	endpoint Endpoint,
	request SearchRequest,
) (SearchPage, error) {
	criteria, limit, err := searchCriteria(request)
	if err != nil {
		return SearchPage{}, err
	}
	if request.BeforeUID == 1 {
		return SearchPage{}, nil
	}
	session, connection, err := client.open(ctx, endpoint)
	if err != nil {
		return SearchPage{}, classifyReadError("open IMAP session", err)
	}
	defer connection.Close()
	defer session.Terminate()

	capabilities, err := discoverCapabilities(session)
	if err != nil {
		return SearchPage{}, classifyReadError("discover mailboxes", err)
	}
	mailbox, err := mappedMailbox(capabilities, request.Folder)
	if err != nil {
		return SearchPage{}, err
	}
	status, err := session.Select(mailbox, true)
	if err != nil {
		return SearchPage{}, classifyReadError("select mailbox", err)
	}
	uids, err := session.UidSearch(criteria)
	if err != nil {
		return SearchPage{}, classifyReadError("search mailbox", err)
	}
	sort.Slice(uids, func(left, right int) bool {
		return uids[left] > uids[right]
	})
	candidateLimit := maxSearchCandidates
	if request.HasAttachments == nil {
		candidateLimit = limit + 1
	}
	moreCandidates := len(uids) > candidateLimit
	if moreCandidates {
		uids = uids[:candidateLimit]
	}
	if len(uids) == 0 {
		_ = session.Logout()
		return SearchPage{}, nil
	}
	messages, err := fetchUIDMessages(
		session,
		uids,
		[]imap.FetchItem{
			imap.FetchUid,
			imap.FetchEnvelope,
			imap.FetchFlags,
			imap.FetchInternalDate,
			imap.FetchRFC822Size,
			imap.FetchBodyStructure,
		},
	)
	if err != nil {
		return SearchPage{}, classifyReadError("fetch search results", err)
	}
	byUID := make(map[uint32]*imap.Message, len(messages))
	for _, message := range messages {
		byUID[message.Uid] = message
	}
	page := SearchPage{Messages: make([]MessageSummary, 0, limit)}
	var lastInspected uint32
	for _, uid := range uids {
		lastInspected = uid
		message := byUID[uid]
		if message == nil {
			continue
		}
		summary, err := summarizeMessage(
			message,
			mailbox,
			status.UidValidity,
		)
		if err != nil {
			return SearchPage{}, err
		}
		if request.HasAttachments != nil &&
			summary.HasAttachments != *request.HasAttachments {
			continue
		}
		page.Messages = append(page.Messages, summary)
		if len(page.Messages) == limit {
			break
		}
	}
	if lastInspected != 0 &&
		(moreCandidates ||
			lastInspected != uids[len(uids)-1]) {
		page.BeforeUID = lastInspected
	}
	_ = session.Logout()
	return page, nil
}

func (client *IMAPClient) GetMessage(
	ctx context.Context,
	endpoint Endpoint,
	ref MessageRef,
) (Message, error) {
	if err := validateMessageRef(ref); err != nil {
		return Message{}, err
	}
	session, connection, err := client.open(ctx, endpoint)
	if err != nil {
		return Message{}, classifyReadError("open IMAP session", err)
	}
	defer connection.Close()
	defer session.Terminate()

	status, err := session.Select(ref.Mailbox, true)
	if err != nil {
		return Message{}, classifyReadError("select message mailbox", err)
	}
	if status.UidValidity != ref.UIDValidity {
		return Message{}, conflictError("get message")
	}
	raw, err := fetchOneUID(session, ref.UID, messageMetadataItems(false))
	if err != nil {
		return Message{}, classifyReadError("fetch message metadata", err)
	}
	if raw == nil {
		return Message{}, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "get message",
		}
	}
	part, attachments, err := selectBodyPart(raw.BodyStructure)
	if err != nil {
		return Message{}, err
	}
	body := Body{Format: "text"}
	if part.MediaType != "" {
		body, err = fetchMessageBody(session, ref.UID, part)
		if err != nil {
			return Message{}, classifyReadError("fetch message body", err)
		}
	}
	message, err := normalizeMessage(
		raw,
		ref,
		body,
		len(attachments) > 0,
	)
	if err != nil {
		return Message{}, err
	}
	_ = session.Logout()
	return message, nil
}

func (client *IMAPClient) ListAttachments(
	ctx context.Context,
	endpoint Endpoint,
	ref MessageRef,
) ([]Attachment, error) {
	if err := validateMessageRef(ref); err != nil {
		return nil, err
	}
	session, connection, err := client.open(ctx, endpoint)
	if err != nil {
		return nil, classifyReadError("open IMAP session", err)
	}
	defer connection.Close()
	defer session.Terminate()

	status, err := session.Select(ref.Mailbox, true)
	if err != nil {
		return nil, classifyReadError("select attachment mailbox", err)
	}
	if status.UidValidity != ref.UIDValidity {
		return nil, conflictError("list attachments")
	}
	raw, err := fetchOneUID(
		session,
		ref.UID,
		[]imap.FetchItem{imap.FetchUid, imap.FetchBodyStructure},
	)
	if err != nil {
		return nil, classifyReadError("fetch attachment metadata", err)
	}
	if raw == nil {
		return nil, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "list attachments",
		}
	}
	_, attachments, err := selectBodyPart(raw.BodyStructure)
	if err != nil {
		return nil, err
	}
	_ = session.Logout()
	return attachments, nil
}

func (client *IMAPClient) AppendDraft(
	ctx context.Context,
	endpoint Endpoint,
	content DraftContent,
) (DraftRef, error) {
	message, err := BuildDraftMessage(content)
	if err != nil {
		return DraftRef{}, &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "append draft",
			Cause:     err,
		}
	}
	handle, err := client.openBudgeted(
		ctx,
		endpoint,
		draftAppendBudget(client.options, len(message)),
	)
	if err != nil {
		return DraftRef{}, classifyReadError("open IMAP session", err)
	}
	session, connection := handle.session, handle.connection
	defer connection.Close()
	defer session.Terminate()

	capabilities, err := discoverCapabilities(session)
	if err != nil {
		return DraftRef{}, classifyReadError("discover draft mailbox", err)
	}
	mailbox := capabilities.Mailbox(FolderDrafts)
	if mailbox == "" || !capabilities.Has("SPECIAL-USE") ||
		!capabilities.Has("UIDPLUS") {
		return DraftRef{}, unsupportedError("append draft")
	}
	// appendDraftMessage scopes the bulk timeout to the APPEND literal only;
	// its post-append metadata commands restore the tight default.
	ref, err := appendDraftMessage(
		session,
		handle,
		mailbox,
		message,
		capabilities.Has("CONDSTORE"),
	)
	if err != nil {
		if limitErr := handle.limitError("append draft"); limitErr != nil {
			return DraftRef{}, limitErr
		}
		return DraftRef{}, err
	}
	_ = session.Logout()
	return ref, nil
}

func (client *IMAPClient) GetDraft(
	ctx context.Context,
	endpoint Endpoint,
	ref DraftRef,
) (Draft, error) {
	if err := validateDraftRef(ref, false); err != nil {
		return Draft{}, err
	}
	handle, err := client.openBudgeted(
		ctx,
		endpoint,
		draftBodyFetchBudget(client.options),
	)
	if err != nil {
		return Draft{}, classifyReadError("open IMAP session", err)
	}
	session, connection, ceiling := handle.session, handle.connection, handle.ceiling
	defer connection.Close()
	defer session.Terminate()

	capabilities, err := discoverCapabilities(session)
	if err != nil {
		return Draft{}, classifyReadError("discover draft mailbox", err)
	}
	if capabilities.Mailbox(FolderDrafts) != ref.Mailbox {
		return Draft{}, conflictError("get draft")
	}
	if !capabilities.Has("CONDSTORE") {
		return Draft{}, unsupportedError("get draft")
	}
	status, err := session.Select(ref.Mailbox, true)
	if err != nil {
		return Draft{}, classifyReadError("select draft mailbox", err)
	}
	if status.UidValidity != ref.UIDValidity {
		return Draft{}, conflictError("get draft")
	}
	raw, err := fetchOneUID(session, ref.UID, messageMetadataItems(true))
	if err != nil {
		return Draft{}, classifyReadError("fetch draft", err)
	}
	if raw == nil {
		return Draft{}, conflictError("get draft")
	}
	modSeq, err := messageModSeq(raw)
	if err != nil {
		return Draft{}, unsupportedError("get draft")
	}
	if ref.ModSeq != 0 && modSeq != ref.ModSeq {
		return Draft{}, conflictError("get draft")
	}
	part, attachments, err := selectBodyPart(raw.BodyStructure)
	if err != nil {
		return Draft{}, err
	}
	body := Body{Format: "text"}
	if part.MediaType != "" {
		previousTimeout := session.Timeout
		session.Timeout = time.Until(ceiling)
		body, err = fetchDraftBody(session, ref.UID, part)
		session.Timeout = previousTimeout
		if err != nil {
			if limitErr := handle.limitError("fetch draft body"); limitErr != nil {
				return Draft{}, limitErr
			}
			return Draft{}, classifyReadError("fetch draft body", err)
		}
	}
	content, metadataTruncated, err := draftContent(raw.Envelope, body)
	if err != nil {
		return Draft{}, err
	}
	_ = session.Logout()
	return Draft{
		Ref: DraftRef{
			Mailbox:     ref.Mailbox,
			UIDValidity: ref.UIDValidity,
			UID:         ref.UID,
			ModSeq:      modSeq,
		},
		Content:     content,
		Attachments: attachments,
		Truncated: body.Truncated ||
			metadataTruncated ||
			attachmentMetadataTruncated(attachments),
	}, nil
}

func attachmentMetadataTruncated(attachments []Attachment) bool {
	for _, attachment := range attachments {
		if attachment.Truncated {
			return true
		}
	}
	return false
}

func (client *IMAPClient) ReplaceDraft(
	ctx context.Context,
	endpoint Endpoint,
	oldRef DraftRef,
	content DraftContent,
) (DraftRef, error) {
	if err := validateDraftRef(oldRef, true); err != nil {
		return DraftRef{}, err
	}
	message, err := BuildDraftMessage(content)
	if err != nil {
		return DraftRef{}, &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "replace draft",
			Cause:     err,
		}
	}
	handle, err := client.openBudgeted(
		ctx,
		endpoint,
		draftAppendBudget(client.options, len(message)),
	)
	if err != nil {
		return DraftRef{}, classifyReadError("open IMAP session", err)
	}
	session, connection := handle.session, handle.connection
	defer connection.Close()
	defer session.Terminate()

	capabilities, err := discoverCapabilities(session)
	if err != nil {
		return DraftRef{}, classifyReadError("discover draft mailbox", err)
	}
	if !capabilities.SafeDraftMutation() ||
		capabilities.Mailbox(FolderDrafts) != oldRef.Mailbox {
		return DraftRef{}, unsupportedError("replace draft")
	}
	status, err := session.Select(oldRef.Mailbox, false)
	if err != nil {
		return DraftRef{}, classifyReadError("select draft mailbox", err)
	}
	if status.UidValidity != oldRef.UIDValidity {
		return DraftRef{}, conflictError("replace draft")
	}
	current, err := fetchOneUID(
		session,
		oldRef.UID,
		[]imap.FetchItem{
			imap.FetchUid,
			imap.FetchFlags,
			imap.FetchItem("MODSEQ"),
		},
	)
	if err != nil {
		return DraftRef{}, classifyReadError("check draft revision", err)
	}
	if current == nil || hasFlag(current.Flags, imap.DeletedFlag) {
		return DraftRef{}, conflictError("replace draft")
	}
	modSeq, err := messageModSeq(current)
	if err != nil || modSeq != oldRef.ModSeq {
		return DraftRef{}, conflictError("replace draft")
	}
	// appendDraftMessage scopes the bulk timeout to the APPEND literal only;
	// the subsequent SELECT/STORE/EXPUNGE run on the tight default.
	newRef, err := appendDraftMessage(
		session,
		handle,
		oldRef.Mailbox,
		message,
		true,
	)
	if err != nil {
		if limitErr := handle.limitError("replace draft"); limitErr != nil {
			return DraftRef{}, limitErr
		}
		return DraftRef{}, err
	}
	if newRef.ModSeq == 0 {
		return DraftRef{}, mutationUnknown(
			"read replacement draft revision after append",
			nil,
		)
	}
	reselected, err := session.Select(oldRef.Mailbox, false)
	if err != nil {
		return DraftRef{}, mutationUnknown("resume draft replacement", err)
	}
	if reselected.UidValidity != oldRef.UIDValidity {
		return DraftRef{}, mutationUnknown("resume draft replacement", nil)
	}
	if err := conditionallyDeleteDraft(session, oldRef); err != nil {
		return DraftRef{}, mutationUnknown(
			"conditionally delete old draft after append",
			err,
		)
	}
	if err := uidExpunge(session, oldRef.UID); err != nil {
		return DraftRef{}, mutationUnknown(
			"expunge old draft after append",
			err,
		)
	}
	_ = session.Logout()
	return newRef, nil
}

func searchCriteria(
	request SearchRequest,
) (*imap.SearchCriteria, int, error) {
	limit := request.Limit
	if limit == 0 {
		limit = defaultSearchLimit
	}
	if limit < 1 || limit > maxSearchResults ||
		len(request.Text) > maxSearchTextBytes ||
		len(request.From) > maxSearchAddresses ||
		len(request.To) > maxSearchAddresses {
		return nil, 0, &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "search mailbox",
		}
	}
	criteria := imap.NewSearchCriteria()
	if request.Text != "" {
		criteria.Text = []string{request.Text}
	}
	for _, address := range request.From {
		if err := validateSearchValue(address, maxAddressBytes); err != nil {
			return nil, 0, err
		}
		criteria.Header.Add("From", address)
	}
	for _, address := range request.To {
		if err := validateSearchValue(address, maxAddressBytes); err != nil {
			return nil, 0, err
		}
		criteria.Header.Add("To", address)
	}
	if request.ReceivedAfter != nil {
		criteria.Since = request.ReceivedAfter.UTC()
	}
	if request.ReceivedBefore != nil {
		criteria.Before = request.ReceivedBefore.UTC()
	}
	if request.Unread != nil {
		if *request.Unread {
			criteria.WithoutFlags = append(
				criteria.WithoutFlags,
				imap.SeenFlag,
			)
		} else {
			criteria.WithFlags = append(
				criteria.WithFlags,
				imap.SeenFlag,
			)
		}
	}
	if request.BeforeUID > 0 {
		criteria.Uid = new(imap.SeqSet)
		if request.BeforeUID > 1 {
			criteria.Uid.AddRange(1, request.BeforeUID-1)
		}
	}
	return criteria, limit, nil
}

func validateSearchValue(value string, limit int) error {
	if strings.TrimSpace(value) == "" ||
		len(value) > limit ||
		strings.ContainsAny(value, "\r\n\x00") {
		return &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "search mailbox",
		}
	}
	return nil
}

func mappedMailbox(
	capabilities Capabilities,
	folder string,
) (string, error) {
	if folder == "" {
		folder = FolderInbox
	}
	switch folder {
	case FolderInbox, FolderAll, FolderArchive, FolderDrafts,
		FolderFlagged, FolderJunk, FolderSent, FolderTrash:
	default:
		return "", &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "map mailbox folder",
		}
	}
	mailbox := capabilities.Mailbox(folder)
	if mailbox == "" {
		return "", unsupportedError("map mailbox folder")
	}
	return mailbox, nil
}

func fetchUIDMessages(
	session *imapclient.Client,
	uids []uint32,
	items []imap.FetchItem,
) ([]*imap.Message, error) {
	sequence := new(imap.SeqSet)
	for _, uid := range uids {
		if uid == 0 {
			continue
		}
		sequence.AddNum(uid)
	}
	messages := make(chan *imap.Message, min(len(uids)+1, maxSearchCandidates+1))
	fetchErr := make(chan error, 1)
	logging.Go(slog.Default(), func() {
		fetchErr <- session.UidFetch(sequence, items, messages)
	})
	result := make([]*imap.Message, 0, len(uids))
	overflow := false
	for message := range messages {
		if len(result) >= len(uids) {
			overflow = true
			continue
		}
		result = append(result, message)
	}
	if err := <-fetchErr; err != nil {
		return nil, err
	}
	if overflow {
		return nil, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "fetch IMAP messages",
		}
	}
	return result, nil
}

func fetchOneUID(
	session *imapclient.Client,
	uid uint32,
	items []imap.FetchItem,
) (*imap.Message, error) {
	messages, err := fetchUIDMessages(session, []uint32{uid}, items)
	if err != nil {
		return nil, err
	}
	if len(messages) == 0 {
		return nil, nil
	}
	if len(messages) != 1 || messages[0].Uid != uid {
		return nil, &ProtocolError{
			Class:     ErrorFailed,
			Operation: "fetch IMAP message",
		}
	}
	return messages[0], nil
}

func messageMetadataItems(modSeq bool) []imap.FetchItem {
	items := []imap.FetchItem{
		imap.FetchUid,
		imap.FetchEnvelope,
		imap.FetchFlags,
		imap.FetchInternalDate,
		imap.FetchRFC822Size,
		imap.FetchBodyStructure,
	}
	if modSeq {
		items = append(items, imap.FetchItem("MODSEQ"))
	}
	return items
}

func fetchMessageBody(
	session *imapclient.Client,
	uid uint32,
	part bodyPart,
) (Body, error) {
	section := &imap.BodySectionName{
		BodyPartName: imap.BodyPartName{Path: part.Path},
		Peek:         true,
		Partial:      []int{0, maxDraftBodyBytes},
	}
	message, err := fetchOneUID(
		session,
		uid,
		[]imap.FetchItem{imap.FetchUid, section.FetchItem()},
	)
	if err != nil {
		return Body{}, err
	}
	if message == nil {
		return Body{}, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "fetch message body",
		}
	}
	literal := message.GetBody(section)
	if literal == nil || literal.Len() > maxDraftBodyBytes {
		return Body{}, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "fetch message body",
		}
	}
	raw, err := io.ReadAll(io.LimitReader(literal, maxDraftBodyBytes+1))
	if err != nil {
		return Body{}, err
	}
	return decodeBodyPart(part, raw)
}

// fetchDraftBody reads one draft body part EXACTLY: the encoded fetch budget
// exceeds the decoded body limit (quoted-printable inflates non-ASCII bodies),
// the returned octets must match the declared BODYSTRUCTURE size, and the
// decoded size gets a hard LimitReader so a draft can never be silently cut.
func fetchDraftBody(
	session *imapclient.Client,
	uid uint32,
	part bodyPart,
) (Body, error) {
	// An empty body is a valid draft: the schema allows empty content, so a
	// zero-size text leaf decodes to the empty string instead of failing.
	if part.Size == 0 {
		format := "text"
		if part.MediaType == "text/html" {
			format = "html"
		}
		return Body{Format: format, Content: ""}, nil
	}
	// Pre-check the declared ENCODED size so oversized drafts are rejected
	// before any body octets are read.
	if int64(part.Size) > maxDraftBodyFetchBytes {
		return Body{}, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "fetch draft body",
		}
	}
	section := &imap.BodySectionName{
		BodyPartName: imap.BodyPartName{Path: part.Path},
		Peek:         true,
		Partial:      []int{0, maxDraftBodyFetchBytes},
	}
	message, err := fetchOneUID(
		session,
		uid,
		[]imap.FetchItem{imap.FetchUid, section.FetchItem()},
	)
	if err != nil {
		return Body{}, err
	}
	if message == nil {
		return Body{}, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "fetch draft body",
		}
	}
	literal := message.GetBody(section)
	if literal == nil || literal.Len() > maxDraftBodyFetchBytes {
		return Body{}, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "fetch draft body",
		}
	}
	raw, err := io.ReadAll(io.LimitReader(literal, maxDraftBodyFetchBytes+1))
	if err != nil {
		return Body{}, err
	}
	if int64(len(raw)) != int64(part.Size) {
		return Body{}, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "verify draft body",
		}
	}
	return decodeDraftBodyPart(part, raw)
}

func summarizeMessage(
	raw *imap.Message,
	mailbox string,
	uidValidity uint32,
) (MessageSummary, error) {
	if raw == nil || raw.Uid == 0 || raw.Envelope == nil {
		return MessageSummary{}, &ProtocolError{
			Class:     ErrorFailed,
			Operation: "normalize search result",
		}
	}
	from, fromTruncated, err := firstAddress(raw.Envelope.From)
	if err != nil {
		return MessageSummary{}, err
	}
	to, toTruncated, err := imapAddresses(raw.Envelope.To)
	if err != nil {
		return MessageSummary{}, err
	}
	cc, ccTruncated, err := imapAddresses(raw.Envelope.Cc)
	if err != nil {
		return MessageSummary{}, err
	}
	subject := boundedUTF8(raw.Envelope.Subject, maxSubjectBytes)
	_, attachments, err := selectBodyPart(raw.BodyStructure)
	if err != nil {
		return MessageSummary{}, err
	}
	sentAt := raw.Envelope.Date
	if sentAt.IsZero() {
		sentAt = raw.InternalDate
	}
	return MessageSummary{
		Ref: MessageRef{
			Mailbox:     mailbox,
			UIDValidity: uidValidity,
			UID:         raw.Uid,
		},
		From:           from,
		To:             to,
		CC:             cc,
		Subject:        subject,
		SentAt:         sentAt.UTC(),
		SizeBytes:      raw.Size,
		Unread:         !hasFlag(raw.Flags, imap.SeenFlag),
		HasAttachments: len(attachments) > 0,
		Truncated: fromTruncated || toTruncated || ccTruncated ||
			subject != raw.Envelope.Subject,
	}, nil
}

func normalizeMessage(
	raw *imap.Message,
	ref MessageRef,
	body Body,
	hasAttachments bool,
) (Message, error) {
	summary, err := summarizeMessage(
		raw,
		ref.Mailbox,
		ref.UIDValidity,
	)
	if err != nil {
		return Message{}, err
	}
	bcc, bccTruncated, err := imapAddresses(raw.Envelope.Bcc)
	if err != nil {
		return Message{}, err
	}
	return Message{
		Ref:            ref,
		From:           summary.From,
		To:             summary.To,
		CC:             summary.CC,
		BCC:            bcc,
		Subject:        summary.Subject,
		SentAt:         summary.SentAt,
		Body:           body,
		HasAttachments: hasAttachments,
		Truncated: summary.Truncated || bccTruncated ||
			body.Truncated,
	}, nil
}

func draftContent(
	envelope *imap.Envelope,
	body Body,
) (DraftContent, bool, error) {
	if envelope == nil {
		return DraftContent{}, false, &ProtocolError{
			Class:     ErrorFailed,
			Operation: "normalize draft",
		}
	}
	from, fromTruncated, err := firstAddress(envelope.From)
	if err != nil {
		return DraftContent{}, false, err
	}
	to, toTruncated, err := imapAddresses(envelope.To)
	if err != nil {
		return DraftContent{}, false, err
	}
	cc, ccTruncated, err := imapAddresses(envelope.Cc)
	if err != nil {
		return DraftContent{}, false, err
	}
	bcc, bccTruncated, err := imapAddresses(envelope.Bcc)
	if err != nil {
		return DraftContent{}, false, err
	}
	format := body.Format
	if format == "sanitized_html" {
		format = "html"
	}
	subject := boundedUTF8(envelope.Subject, maxSubjectBytes)
	truncated := fromTruncated || toTruncated || ccTruncated ||
		bccTruncated || subject != envelope.Subject ||
		len(to)+len(cc)+len(bcc) > maxSMTPRecipients
	return DraftContent{
		From:       from,
		To:         to,
		CC:         cc,
		BCC:        bcc,
		Subject:    subject,
		BodyFormat: format,
		Body:       body.Content,
	}, truncated, nil
}

func firstAddress(
	addresses []*imap.Address,
) (string, bool, error) {
	values, truncated, err := imapAddresses(addresses)
	if err != nil {
		return "", false, err
	}
	if len(values) == 0 {
		return "", truncated, nil
	}
	return values[0], truncated || len(values) > 1, nil
}

func imapAddresses(
	addresses []*imap.Address,
) ([]string, bool, error) {
	truncated := len(addresses) > maxAddresses
	if len(addresses) > maxAddresses {
		addresses = addresses[:maxAddresses]
	}
	result := make([]string, 0, len(addresses))
	for _, address := range addresses {
		if address == nil ||
			address.MailboxName == "" ||
			address.HostName == "" {
			return nil, false, &ProtocolError{
				Class:     ErrorFailed,
				Operation: "normalize IMAP address",
			}
		}
		value := address.Address()
		bounded := boundedUTF8(value, maxAddressBytes)
		truncated = truncated || bounded != value
		result = append(result, bounded)
	}
	return result, truncated, nil
}

func validateMessageRef(ref MessageRef) error {
	if ref.Mailbox == "" ||
		len(ref.Mailbox) > maxMailboxNameBytes ||
		strings.ContainsAny(ref.Mailbox, "\r\n\x00") ||
		ref.UIDValidity == 0 ||
		ref.UID == 0 {
		return &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "validate message reference",
		}
	}
	return nil
}

func validateDraftRef(ref DraftRef, requireModSeq bool) error {
	if err := validateMessageRef(MessageRef{
		Mailbox:     ref.Mailbox,
		UIDValidity: ref.UIDValidity,
		UID:         ref.UID,
	}); err != nil {
		return err
	}
	if requireModSeq && ref.ModSeq == 0 {
		return &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "validate draft reference",
		}
	}
	return nil
}

func hasFlag(flags []string, wanted string) bool {
	for _, flag := range flags {
		if strings.EqualFold(flag, wanted) {
			return true
		}
	}
	return false
}

func unsupportedError(operation string) error {
	return &ProtocolError{Class: ErrorUnsupported, Operation: operation}
}

func conflictError(operation string) error {
	return &ProtocolError{Class: ErrorConflict, Operation: operation}
}

func classifyReadError(operation string, err error) error {
	var protocolError *ProtocolError
	if err == nil || errors.As(err, &protocolError) {
		return err
	}
	class := ErrorFailed
	if errors.Is(err, errProtocolBudget) {
		class = ErrorLimit
	}
	return &ProtocolError{
		Class:     class,
		Operation: operation,
		Cause:     err,
	}
}
