// Package mailview parses a raw RFC822 message into a structure a web UI can render:
// separate plain-text and HTML bodies, plus a flat list of attachments. It exists
// because internal/smtpserver's own MIME walker (parseMessage in attachments.go) is
// unexported, SMTP-inbound-specific, and only concatenates every text/* part into one
// blob — a webmail reader needs to keep text/plain and text/html distinct (so it can
// prefer HTML but still offer a plain-text view) and needs real attachment metadata
// for download links, not just a body string.
package mailview

import (
	"bytes"
	"encoding/base64"
	"io"
	"mime"
	"mime/multipart"
	"net/mail"
	"path/filepath"
	"strings"
)

// Header is the small set of top-level headers a message view needs — never the full
// header block (this isn't a general-purpose header inspector).
type Header struct {
	From, To, Cc, Subject, Date, MessageID string
}

// Attachment is one file extracted from the message, decoded to its real bytes (never
// left as raw base64/quoted-printable text).
type Attachment struct {
	Filename    string
	ContentType string
	Data        []byte
	// ContentID is the part's Content-Id header (RFC 2392), angle brackets stripped,
	// or "" if absent. An HTML body referencing this part inline (<img src="cid:...">)
	// only renders once that reference is resolved against this — see
	// webui.inlineContentIDImages, the caller responsible for doing so.
	ContentID string
}

// Message is the parsed result. TextBody/HTMLBody are independently populated when
// present (e.g. a multipart/alternative body yields both) — never merged — so a
// caller can prefer HTML but still fall back to plain text.
type Message struct {
	Header      Header
	TextBody    string
	HTMLBody    string
	Attachments []Attachment
}

// Parse walks raw's MIME structure (recursing into nested multiparts, e.g. a
// multipart/alternative inside a multipart/mixed) and classifies every leaf part as
// the text body, the HTML body, or an attachment.
func Parse(raw []byte) (*Message, error) {
	msg, err := mail.ReadMessage(bytes.NewReader(raw))
	if err != nil {
		return nil, err
	}
	m := &Message{Header: Header{
		From:      msg.Header.Get("From"),
		To:        msg.Header.Get("To"),
		Cc:        msg.Header.Get("Cc"),
		Subject:   msg.Header.Get("Subject"),
		Date:      msg.Header.Get("Date"),
		MessageID: msg.Header.Get("Message-Id"),
	}}

	mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
	if err != nil {
		mediaType = "text/plain"
	}
	if strings.HasPrefix(mediaType, "multipart/") {
		if err := walkMultipart(m, msg.Body, params["boundary"]); err != nil {
			return nil, err
		}
		return m, nil
	}

	data, _ := io.ReadAll(msg.Body)
	data = decodeContentTransferEncoding(msg.Header.Get("Content-Transfer-Encoding"), data)
	if mediaType == "text/html" {
		m.HTMLBody = string(data)
	} else {
		m.TextBody = string(data)
	}
	return m, nil
}

func walkMultipart(m *Message, r io.Reader, boundary string) error {
	if boundary == "" {
		return nil
	}
	mr := multipart.NewReader(r, boundary)
	for {
		part, err := mr.NextPart()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			// Tolerate a malformed trailing part rather than losing everything
			// already parsed — a webmail reader should show what it can.
			return nil
		}

		mediaType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
		if err != nil {
			mediaType = "text/plain"
		}
		disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))

		data, _ := io.ReadAll(part)
		data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)

		if strings.HasPrefix(mediaType, "multipart/") {
			walkMultipart(m, bytes.NewReader(data), params["boundary"])
			continue
		}

		filename := dispParams["filename"]
		if filename == "" {
			filename = params["name"]
		}
		contentID := strings.Trim(part.Header.Get("Content-Id"), "<>")

		switch {
		case disp == "attachment" || (filename != "" && disp != "inline"):
			m.Attachments = append(m.Attachments, Attachment{
				Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
			})
		case mediaType == "text/html":
			m.HTMLBody += string(data)
		case strings.HasPrefix(mediaType, "text/"):
			if m.TextBody != "" {
				m.TextBody += "\n"
			}
			m.TextBody += string(data)
		case filename != "" || contentID != "":
			// Inline non-text part (e.g. an embedded image referenced by the HTML
			// body via cid:) with no explicit disposition and possibly no filename
			// either — still worth surfacing rather than silently dropping it.
			if filename == "" {
				filename = contentID
			}
			m.Attachments = append(m.Attachments, Attachment{
				Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
			})
		}
	}
}

// contentTypeFor mirrors smtpserver's getContentType: prefer the part's own
// declared type, fall back to extension sniffing for the generic default.
func contentTypeFor(mediaType, filename string) string {
	if mediaType != "" && mediaType != "application/octet-stream" {
		return mediaType
	}
	if guessed := mime.TypeByExtension(filepath.Ext(filename)); guessed != "" {
		return guessed
	}
	return "application/octet-stream"
}

// decodeContentTransferEncoding mirrors smtpserver's identically-named helper:
// mime/multipart.Reader only auto-decodes quoted-printable transparently, never
// base64, so that case needs manual decoding or attachments/HTML bodies come out as
// raw base64 text instead of their real bytes.
func decodeContentTransferEncoding(cte string, data []byte) []byte {
	if !strings.EqualFold(strings.TrimSpace(cte), "base64") {
		return data
	}
	cleaned := make([]byte, 0, len(data))
	for _, b := range data {
		switch b {
		case ' ', '\t', '\r', '\n':
			continue
		default:
			cleaned = append(cleaned, b)
		}
	}
	decoded, err := base64.StdEncoding.DecodeString(string(cleaned))
	if err != nil {
		return data
	}
	return decoded
}
