package protocol

import (
	"bytes"
	"context"
	"encoding/base64"
	"fmt"
	stdhtml "html"
	"io"
	"mime"
	"mime/multipart"
	netmail "net/mail"
	"strings"
	"testing"
	"time"

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

func TestBuildDraftMessageWritesBoundedMIMEWithoutHeaderInjection(
	t *testing.T,
) {
	t.Parallel()

	message, err := BuildDraftMessage(DraftContent{
		From:       "sender@example.com",
		To:         []string{"to@example.com"},
		CC:         []string{"cc@example.com"},
		BCC:        []string{"bcc@example.com"},
		Subject:    "Project update",
		BodyFormat: "text",
		Body:       "Hello",
	})
	require.NoError(t, err)
	text := string(message)
	require.Contains(t, text, "From: <sender@example.com>")
	require.Contains(t, text, "To: <to@example.com>")
	require.Contains(t, text, "Bcc: <bcc@example.com>")
	require.Contains(t, text, "Subject: Project update")
	require.Contains(t, text, "Hello")

	_, err = BuildDraftMessage(DraftContent{
		From:       "sender@example.com",
		To:         []string{"to@example.com"},
		Subject:    "safe\r\nBcc: attacker@example.com",
		BodyFormat: "text",
		Body:       "Hello",
	})
	require.ErrorContains(t, err, "subject")
}

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

	_, err := BuildDraftMessage(DraftContent{
		From:       "sender@example.com",
		To:         []string{"to@example.com"},
		Subject:    "Large",
		BodyFormat: "text",
		Body:       strings.Repeat("x", maxDraftBodyBytes+1),
	})
	require.ErrorContains(t, err, "large")
}

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

	parts := make([]*imap.BodyStructure, maxAttachments+1)
	for index := range parts {
		parts[index] = &imap.BodyStructure{
			MIMEType:    "application",
			MIMESubType: "octet-stream",
			Params:      map[string]string{},
			Disposition: "attachment",
			DispositionParams: map[string]string{
				"filename": "file.bin",
			},
		}
	}
	_, _, err := selectBodyPart(&imap.BodyStructure{
		MIMEType:    "multipart",
		MIMESubType: "mixed",
		Parts:       parts,
	})
	require.Equal(t, ErrorLimit, ErrorClass(err))

	body, err := decodeBodyPart(
		bodyPart{
			MediaType: "text/html",
			Encoding:  "7BIT",
			Size:      maxDraftBodyBytes + 1,
		},
		[]byte("<p>Hello</p><img src=x onerror=alert(1)>"),
	)
	require.NoError(t, err)
	require.Equal(t, "sanitized_html", body.Format)
	require.Equal(t, "<pre>Hello</pre>", body.Content)
	require.True(t, body.Truncated)
	require.NotContains(t, body.Content, "<img")
}

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

	raw := []byte(strings.Repeat("&", 200*1024))
	require.Less(t, len(raw), maxDraftBodyBytes)

	plainText, err := htmlToPlainText(string(raw))
	require.NoError(t, err)
	sanitized := "<pre>" + stdhtml.EscapeString(plainText) + "</pre>"
	require.Greater(t, len(sanitized), maxDraftBodyBytes)

	part, attachments, err := selectBodyPart(&imap.BodyStructure{
		MIMEType:    "text",
		MIMESubType: "html",
		Encoding:    "7BIT",
		Size:        uint32(len(raw)),
	})
	require.NoError(t, err)
	require.Empty(t, attachments)

	body, err := decodeBodyPart(part, raw)
	require.NoError(t, err)
	require.Equal(t, "sanitized_html", body.Format)
	require.Len(t, body.Content, maxDraftBodyBytes)
	require.True(t, body.Truncated)
}

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

	_, attachments, err := selectBodyPart(&imap.BodyStructure{
		MIMEType:    "application",
		MIMESubType: strings.Repeat("x", maxMediaTypeBytes),
		Params:      map[string]string{},
		Disposition: "attachment",
		DispositionParams: map[string]string{
			"filename": strings.Repeat("f", maxFilenameBytes+1),
		},
	})
	require.NoError(t, err)
	require.Len(t, attachments, 1)
	require.True(t, attachments[0].Truncated)
	require.LessOrEqual(t, len(attachments[0].Filename), maxFilenameBytes)
	require.LessOrEqual(t, len(attachments[0].MediaType), maxMediaTypeBytes)
}

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

	_, _, err := selectBodyPart(nil)
	require.Equal(t, ErrorFailed, ErrorClass(err))
}

func contextWithTestTimeout(t *testing.T) context.Context {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	t.Cleanup(cancel)
	return ctx
}

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

	blob := []byte("%PDF-1.4 " + strings.Repeat("a", 300))
	message, err := BuildDraftMessage(DraftContent{
		From:       "sender@example.com",
		To:         []string{"to@example.com"},
		BCC:        []string{"bcc@example.com"},
		Subject:    "With attachments",
		BodyFormat: "text",
		Body:       "See attached",
		Attachments: []DraftAttachment{{
			Filename:  "réport final.pdf",
			MediaType: "application/pdf",
			Blob:      blob,
		}},
	})
	require.NoError(t, err)
	text := string(message)
	require.Contains(t, text, "multipart/mixed")
	require.Contains(t, strings.ToLower(text), "mime-version: 1.0")
	require.Contains(t, text, "Bcc: <bcc@example.com>")
	require.Contains(t, text, "quoted-printable")

	reader := bytes.NewReader(message)
	parsed, err := netmail.ReadMessage(reader)
	require.NoError(t, err)
	mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type"))
	require.NoError(t, err)
	require.Equal(t, "multipart/mixed", mediaType)
	require.NotEmpty(t, params["boundary"])
	parts := multipart.NewReader(parsed.Body, params["boundary"])
	var attachmentCount int
	for {
		part, err := parts.NextPart()
		if err == io.EOF {
			break
		}
		require.NoError(t, err)
		disposition, dispositionParams, err := mime.ParseMediaType(
			part.Header.Get("Content-Disposition"),
		)
		if err != nil || disposition != "attachment" {
			body, readErr := io.ReadAll(part)
			require.NoError(t, readErr)
			require.Contains(t, string(body), "See attached")
			continue
		}
		attachmentCount++
		require.Equal(t, "attachment", disposition)
		require.NotEmpty(t, dispositionParams["filename"])
		decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, part))
		require.NoError(t, err)
		require.Equal(t, blob, decoded)
	}
	require.Equal(t, 1, attachmentCount)
	for _, line := range strings.Split(string(message), "\r\n") {
		require.LessOrEqual(t, len(line), 998)
	}
}

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

	base := DraftContent{
		From:       "sender@example.com",
		To:         []string{"to@example.com"},
		Subject:    "Invalid",
		BodyFormat: "text",
		Body:       "Hello",
	}
	emptyFilename := base
	emptyFilename.Attachments = []DraftAttachment{{
		Filename:  "  ",
		MediaType: "application/pdf",
		Blob:      []byte("x"),
	}}
	_, err := BuildDraftMessage(emptyFilename)
	require.ErrorContains(t, err, "filename")

	pathFilename := base
	pathFilename.Attachments = []DraftAttachment{{
		Filename:  "../escape.pdf",
		MediaType: "application/pdf",
		Blob:      []byte("x"),
	}}
	_, err = BuildDraftMessage(pathFilename)
	require.ErrorContains(t, err, "filename")

	badMediaType := base
	badMediaType.Attachments = []DraftAttachment{{
		Filename:  "report.pdf",
		MediaType: "not a media type;;;",
		Blob:      []byte("x"),
	}}
	_, err = BuildDraftMessage(badMediaType)
	require.ErrorContains(t, err, "media type")

	emptyBlob := base
	emptyBlob.Attachments = []DraftAttachment{{
		Filename:  "report.pdf",
		MediaType: "application/pdf",
		Blob:      nil,
	}}
	_, err = BuildDraftMessage(emptyBlob)
	require.ErrorContains(t, err, "size")

	oversized := base
	oversized.Attachments = []DraftAttachment{{
		Filename:  "report.pdf",
		MediaType: "application/pdf",
		Blob:      make([]byte, MaxAttachmentPartBytes+1),
	}}
	_, err = BuildDraftMessage(oversized)
	require.ErrorContains(t, err, "size")

	tooMany := base
	tooMany.Attachments = make([]DraftAttachment, MaxMessageAttachments+1)
	for index := range tooMany.Attachments {
		tooMany.Attachments[index] = DraftAttachment{
			Filename:  fmt.Sprintf("f%d.txt", index),
			MediaType: "text/plain",
			Blob:      []byte("x"),
		}
	}
	_, err = BuildDraftMessage(tooMany)
	require.ErrorContains(t, err, "exceeded limit")
}
