package protocol

import (
	"context"
	"io"
	"strings"
	"time"

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

func (client *IMAPClient) GetAttachment(
	ctx context.Context,
	endpoint Endpoint,
	ref MessageRef,
	part []int,
) (AttachmentContent, error) {
	if err := validateMessageRef(ref); err != nil {
		return AttachmentContent{}, err
	}
	if len(part) == 0 || len(part) > maxBodyParts {
		return AttachmentContent{}, &ProtocolError{
			Class:     ErrorInvalid,
			Operation: "get attachment",
		}
	}
	for _, segment := range part {
		if segment < 1 || segment > maxBodyParts {
			return AttachmentContent{}, &ProtocolError{
				Class:     ErrorInvalid,
				Operation: "get attachment",
			}
		}
	}
	handle, err := client.openBudgeted(
		ctx,
		endpoint,
		attachmentFetchBudget(client.options),
	)
	if err != nil {
		return AttachmentContent{}, classifyReadError("open IMAP session", err)
	}
	session, connection, ceiling := handle.session, handle.connection, handle.ceiling
	defer connection.Close()
	defer session.Terminate()

	status, err := session.Select(ref.Mailbox, true)
	if err != nil {
		return AttachmentContent{}, classifyReadError("select attachment mailbox", err)
	}
	if status.UidValidity != ref.UIDValidity {
		return AttachmentContent{}, conflictError("get attachment")
	}
	raw, err := fetchOneUID(
		session,
		ref.UID,
		[]imap.FetchItem{imap.FetchUid, imap.FetchBodyStructure},
	)
	if err != nil {
		return AttachmentContent{}, classifyReadError("fetch attachment metadata", err)
	}
	if raw == nil {
		return AttachmentContent{}, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "get attachment",
		}
	}
	bodyPart := findAttachmentPart(raw.BodyStructure, part)
	if bodyPart == nil {
		return AttachmentContent{}, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "get attachment",
		}
	}
	// The bounded bulk command gets a per-command timeout sized to the
	// remaining operation budget; metadata commands stay on the tight
	// default.
	previousTimeout := session.Timeout
	session.Timeout = time.Until(ceiling)
	blob, err := fetchAttachmentBlob(session, ref.UID, part, bodyPart)
	session.Timeout = previousTimeout
	if err != nil {
		if limitErr := handle.limitError("fetch attachment part"); limitErr != nil {
			return AttachmentContent{}, limitErr
		}
		return AttachmentContent{}, err
	}
	_ = session.Logout()
	return AttachmentContent{
		Part:      append([]int(nil), part...),
		Filename:  boundedUTF8(bodyPart.Filename, maxFilenameBytes),
		MediaType: strings.ToLower(bodyPart.MediaType),
		Blob:      blob,
	}, nil
}

// fetchAttachmentBlob reads the exact encoded octets of one attachment part
// and decodes them. The declared size passed the budget pre-check and the
// partial window is at least as large, so a complete server must return every
// encoded octet: a short body means the section was cut mid-transfer and must
// never be decoded, because a truncated base64 block without padding would
// otherwise decode silently into fewer bytes.
func fetchAttachmentBlob(
	session *imapclient.Client,
	uid uint32,
	part []int,
	bodyPart *bodyPart,
) ([]byte, error) {
	if bodyPart.Size == 0 || int64(bodyPart.Size) > maxAttachmentFetchBytes {
		return nil, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "get attachment",
		}
	}
	section := &imap.BodySectionName{
		BodyPartName: imap.BodyPartName{Path: append([]int(nil), part...)},
		Peek:         true,
		Partial:      []int{0, maxAttachmentFetchBytes},
	}
	fetched, err := fetchOneUID(
		session,
		uid,
		[]imap.FetchItem{imap.FetchUid, section.FetchItem()},
	)
	if err != nil {
		return nil, classifyReadError("fetch attachment part", err)
	}
	if fetched == nil {
		return nil, &ProtocolError{
			Class:     ErrorNotFound,
			Operation: "get attachment",
		}
	}
	literal := fetched.GetBody(section)
	if literal == nil || literal.Len() > maxAttachmentFetchBytes {
		return nil, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "fetch attachment part",
		}
	}
	rawBody, err := io.ReadAll(io.LimitReader(literal, maxAttachmentFetchBytes+1))
	if err != nil {
		return nil, err
	}
	if int64(len(rawBody)) != int64(bodyPart.Size) {
		return nil, &ProtocolError{
			Class:     ErrorLimit,
			Operation: "verify attachment part",
		}
	}
	return decodeAttachmentPart(*bodyPart, rawBody)
}

// findAttachmentPart walks the BODYSTRUCTURE to the exact part path and only
// accepts leaves the shared classifier treats as attachments.
func findAttachmentPart(
	structure *imap.BodyStructure,
	part []int,
) *bodyPart {
	if structure == nil || len(part) == 0 {
		return nil
	}
	current := structure
	for index, segment := range part {
		if segment < 1 || segment > len(current.Parts) {
			return nil
		}
		current = current.Parts[segment-1]
		if index < len(part)-1 && len(current.Parts) == 0 {
			return nil
		}
	}
	if len(current.Parts) > 0 {
		return nil
	}
	mediaType := strings.ToLower(current.MIMEType + "/" + current.MIMESubType)
	filename, _ := current.Filename()
	if !strings.EqualFold(current.Disposition, "attachment") && filename == "" {
		return nil
	}
	boundedFilename := boundedUTF8(filename, maxFilenameBytes)
	boundedMediaType := boundedUTF8(mediaType, maxMediaTypeBytes)
	return &bodyPart{
		Path:      append([]int(nil), part...),
		MediaType: boundedMediaType,
		Encoding:  current.Encoding,
		Size:      current.Size,
		Filename:  boundedFilename,
	}
}
