package protocol

import (
	"bytes"
	"encoding/base64"
	"mime/quotedprintable"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/emersion/go-imap"
	"github.com/stretchr/testify/require"
)

func TestDecodeDraftBodyPartKeepsExactHTML(t *testing.T) {
	t.Parallel()

	original := `<div>Hello <b>world</b><img src="cid:part1"></div>`
	encoded := quotedPrintableEncode(t, []byte(original))
	body, err := decodeDraftBodyPart(bodyPart{
		Path:      []int{1},
		MediaType: "text/html",
		Encoding:  "QUOTED-PRINTABLE",
		Size:      uint32(len(encoded)),
	}, encoded)
	require.NoError(t, err)
	require.Equal(t, "html", body.Format)
	require.Equal(t, original, body.Content)
	require.False(t, body.Truncated)
	require.NotContains(t, body.Content, "<pre>")
}

func TestDecodeDraftBodyPartMarksOverBudgetTruncated(t *testing.T) {
	t.Parallel()

	raw := []byte(strings.Repeat("a", maxDraftBodyBytes+1))
	body, err := decodeDraftBodyPart(bodyPart{
		Path:      []int{1},
		MediaType: "text/plain",
		Encoding:  "7BIT",
		Size:      uint32(len(raw)),
	}, raw)
	require.NoError(t, err)
	require.True(t, body.Truncated)
}

func TestGetDraftReturnsExactHTMLBodyThroughScriptedIMAP(t *testing.T) {
	t.Parallel()

	original := `<div>Hello <b>world</b><img src="cid:part1"></div>`
	encoded := quotedPrintableEncode(t, []byte(original))
	client, _, done := newHighLevelIMAPClient(t, highLevelIMAPScript{
		bodyContent:   encoded,
		bodyEncoding:  "QUOTED-PRINTABLE",
		bodyMediaType: "text/html",
		bodyRawSize:   uint32(len(encoded)),
		bodySizeSet:   true,
	})
	draft, err := client.GetDraft(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		DraftRef{Mailbox: scriptedDraftMailbox, UIDValidity: 777, UID: 7, ModSeq: 9},
	)
	require.NoError(t, err)
	require.NoError(t, <-done)
	require.Equal(t, "html", draft.Content.BodyFormat)
	require.Equal(t, original, draft.Content.Body)
	require.False(t, draft.Truncated)
	require.Len(t, draft.Attachments, 1)
}

func TestGetDraftRejectsTruncatedDraftBody(t *testing.T) {
	t.Parallel()

	original := strings.Repeat("<p>paragraph</p>", 64)
	encoded := quotedPrintableEncode(t, []byte(original))
	client, _, done := newHighLevelIMAPClient(t, highLevelIMAPScript{
		bodyContent:   encoded[:len(encoded)-24],
		bodyEncoding:  "QUOTED-PRINTABLE",
		bodyMediaType: "text/html",
		bodyRawSize:   uint32(len(encoded)),
	})
	_, err := client.GetDraft(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		DraftRef{Mailbox: scriptedDraftMailbox, UIDValidity: 777, UID: 7, ModSeq: 9},
	)
	require.Error(t, err)
	require.Equal(t, ErrorLimit, ErrorClass(err))
	require.NoError(t, <-done)
}

func quotedPrintableEncode(t *testing.T, payload []byte) []byte {
	t.Helper()
	var builder strings.Builder
	writer := quotedprintable.NewWriter(&builder)
	_, err := writer.Write(payload)
	require.NoError(t, err)
	require.NoError(t, writer.Close())
	return []byte(builder.String())
}

// applyScriptedPartOverrides rewrites the scripted FETCH message with the
// body/attachment overrides a test configured, mirroring what a real server
// would report from the stored message.
func applyScriptedPartOverrides(
	script *highLevelIMAPScript,
	message *imap.Message,
) {
	if script.attachmentRawSize != 0 &&
		len(message.BodyStructure.Parts) > 1 {
		message.BodyStructure.Parts[1].Size = script.attachmentRawSize
	}
	if (script.bodyEncoding != "" || script.bodyMediaType != "" ||
		script.bodyRawSize != 0) &&
		len(message.BodyStructure.Parts) > 0 {
		bodyPart := message.BodyStructure.Parts[0]
		if script.bodyEncoding != "" {
			bodyPart.Encoding = script.bodyEncoding
		}
		if script.bodyMediaType != "" {
			bodyPart.MIMEType, bodyPart.MIMESubType, _ = strings.Cut(
				script.bodyMediaType,
				"/",
			)
		}
		if script.bodySizeSet {
			bodyPart.Size = script.bodyRawSize
		}
		if len(script.bodyContent) > 0 {
			section, err := imap.ParseBodySectionName(
				imap.FetchItem("BODY[1]<0>"),
			)
			if err != nil {
				panic(err)
			}
			// scriptedMessage may already have registered a semantically
			// equal section under a different pointer key.
			for existing := range message.Body {
				if existing.Equal(section) {
					delete(message.Body, existing)
				}
			}
			message.Items[section.FetchItem()] = nil
			message.Body[section] = bytes.NewBuffer(script.bodyContent)
		}
	}
	if script.attachmentFilename != "" &&
		len(message.BodyStructure.Parts) > 1 {
		message.BodyStructure.Parts[1].Params["name"] =
			script.attachmentFilename
		message.BodyStructure.Parts[1].DispositionParams["filename"] =
			script.attachmentFilename
	}
}

// TestDraftAppendAndFetchExceedTwoMiBProtocolBudget pins the operation-level
// bulk budgets: an attachment draft whose encoded APPEND and FETCH payloads
// both exceed the old global 2 MiB connection budget must still succeed.
func TestDraftAppendAndFetchExceedTwoMiBProtocolBudget(t *testing.T) {
	t.Parallel()

	// 3 MiB attachment: encoded base64 is 4 MiB, far above the 2 MiB
	// metadata budget but within the attachment fetch and append budgets.
	blob := bytes.Repeat([]byte("B"), 3<<20)
	attachments := []DraftAttachment{{
		Filename:  "large.bin",
		MediaType: "application/octet-stream",
		Blob:      blob,
	}}
	appendClient, appendScript, appendDone := newHighLevelIMAPClient(t, highLevelIMAPScript{})
	_, err := appendClient.AppendDraft(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		DraftContent{
			From:        "sender@example.com",
			To:          []string{"to@example.com"},
			Subject:     "Large",
			BodyFormat:  "text",
			Body:        "Big attachment",
			Attachments: attachments,
		},
	)
	require.NoError(t, err)
	require.NoError(t, <-appendDone)
	require.Greater(t, len(appendScript.appendedMessage), 2*1024*1024)

	encoded := []byte(base64.StdEncoding.EncodeToString(blob))
	fetchClient, _, fetchDone := newHighLevelIMAPClient(t, highLevelIMAPScript{
		attachmentBody:    encoded,
		attachmentRawSize: uint32(len(encoded)),
	})
	content, err := fetchClient.GetAttachment(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		MessageRef{Mailbox: "INBOX", UIDValidity: 777, UID: 9},
		[]int{2},
	)
	require.NoError(t, err)
	require.NoError(t, <-fetchDone)
	require.Equal(t, blob, content.Blob)
}

func TestGetDraftAcceptsEmptyBody(t *testing.T) {
	t.Parallel()

	client, _, done := newHighLevelIMAPClient(t, highLevelIMAPScript{
		bodyMediaType: "text/plain",
		bodyRawSize:   0,
		bodySizeSet:   true,
	})
	draft, err := client.GetDraft(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		DraftRef{Mailbox: scriptedDraftMailbox, UIDValidity: 777, UID: 7, ModSeq: 9},
	)
	require.NoError(t, err)
	require.NoError(t, <-done)
	// A zero-size body part is a valid empty draft body, not a limit error.
	require.Empty(t, draft.Content.Body)
	require.False(t, draft.Truncated)
	require.Equal(t, "text", draft.Content.BodyFormat)
}

func TestFetchDraftBodyDecodesZeroSizePartAsEmpty(t *testing.T) {
	t.Parallel()

	body, err := decodeDraftBodyPart(bodyPart{
		Path:      []int{1},
		MediaType: "text/html",
		Encoding:  "7BIT",
		Size:      0,
	}, nil)
	require.NoError(t, err)
	require.Equal(t, "html", body.Format)
	require.Empty(t, body.Content)
	require.False(t, body.Truncated)
}

// TestDraftAppendSurvivesSlowLinkBeyondCommandTimeout proves the bounded bulk
// deadline governs the APPEND literal: a throttled transfer that outlasts the
// metadata command timeout still completes, while every metadata command keeps
// the tight default.
func TestDraftAppendSurvivesSlowLinkBeyondCommandTimeout(t *testing.T) {
	t.Parallel()

	connector, server, certificate := newScriptedConnector(t)
	script := &highLevelIMAPScript{
		appendChunkSize:  64 * 1024,
		appendChunkDelay: 20 * time.Millisecond,
	}
	script.mu = new(sync.Mutex)
	done := make(chan error, 1)
	go func() {
		done <- serveHighLevelIMAP(server, certificate, script)
	}()
	client := NewIMAPClient(ClientOptions{
		Connector:           connector,
		CommandTimeout:      400 * time.Millisecond,
		BulkTransferTimeout: 30 * time.Second,
	})
	blob := bytes.Repeat([]byte("L"), 3<<20)
	_, err := client.AppendDraft(
		contextWithTestTimeout(t),
		testIMAPEndpoint(),
		DraftContent{
			From:       "sender@example.com",
			To:         []string{"to@example.com"},
			Subject:    "Slow link",
			BodyFormat: "text",
			Body:       "Throttled append",
			Attachments: []DraftAttachment{{
				Filename:  "slow.bin",
				MediaType: "application/octet-stream",
				Blob:      blob,
			}},
		},
	)
	// The encoded literal is 4 MiB in 64 KiB chunks with 20 ms pauses, so
	// the append takes about 1.3 s: three times the 400 ms command timeout.
	require.NoError(t, err)
	require.NoError(t, <-done)
	require.Greater(t, len(script.appendedMessage), 4*1024*1024)
}
