// Package dsn parses and composes Delivery Status Notification messages, see
// RFC 3464 and RFC 6533.
package dsn

import (
	"bufio"
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/textproto"
	"strconv"
	"strings"
	"time"

	"github.com/mjl-/mox/dkim"
	"github.com/mjl-/mox/message"
	"github.com/mjl-/mox/mlog"
	"github.com/mjl-/mox/mox-"
	"github.com/mjl-/mox/smtp"
)

// Message represents a DSN message, with basic message headers, human-readable text,
// machine-parsable data, and optional original message/headers.
//
// A DSN represents a delayed, failed or successful delivery. Failing incoming
// deliveries over SMTP, and failing outgoing deliveries from the message queue,
// can result in a DSN being sent.
type Message struct {
	SMTPUTF8 bool // Whether the original was received with smtputf8.

	// DSN message From header. E.g. postmaster@ourdomain.example. NOTE:
	// DSNs should be sent with a null reverse path to prevent mail loops.
	// ../rfc/3464:421
	From smtp.Path

	// "To" header, and also SMTP RCP TO to deliver DSN to. Should be taken
	// from original SMTP transaction MAIL FROM.
	// ../rfc/3464:415
	To smtp.Path

	// Message subject header, e.g. describing mail delivery failure.
	Subject string

	// Human-readable text explaining the failure. Line endings should be
	// bare newlines, not \r\n. They are converted to \r\n when composing.
	TextBody string

	// Per-message fields.
	OriginalEnvelopeID string
	ReportingMTA       string // Required.
	DSNGateway         string
	ReceivedFromMTA    smtp.Ehlo // Host from which message was received.
	ArrivalDate        time.Time

	// All per-message fields, including extensions. Only used for parsing,
	// not composing.
	MessageHeader textproto.MIMEHeader

	// One or more per-recipient fields.
	// ../rfc/3464:436
	Recipients []Recipient

	// Original message or headers to include in DSN as third MIME part.
	// Optional. Only used for generating DSNs, not set for parsed DNSs.
	Original []byte
}

// Action is a field in a DSN.
type Action string

// ../rfc/3464:890

const (
	Failed    Action = "failed"
	Delayed   Action = "delayed"
	Delivered Action = "delivered"
	Relayed   Action = "relayed"
	Expanded  Action = "expanded"
)

// ../rfc/3464:1530 ../rfc/6533:370

// Recipient holds the per-recipient delivery-status lines in a DSN.
type Recipient struct {
	// Required fields.
	FinalRecipient smtp.Path // Final recipient of message.
	Action         Action

	// Enhanced status code. First digit indicates permanent or temporary
	// error. If the string contains more than just a status, that
	// additional text is added as comment when composing a DSN.
	Status string

	// Optional fields.
	// Original intended recipient of message. Used with the DSN extensions ORCPT
	// parameter.
	// ../rfc/3464:1197
	OriginalRecipient smtp.Path

	// Remote host that returned an error code. Can also be empty for
	// deliveries.
	RemoteMTA NameIP

	// If RemoteMTA is present, DiagnosticCode is from remote. When
	// creating a DSN, additional text in the string will be added to the
	// DSN as comment.
	DiagnosticCode  string
	LastAttemptDate time.Time
	FinalLogID      string

	// For delayed deliveries, deliveries may be retried until this time.
	WillRetryUntil *time.Time

	// All fields, including extensions. Only used for parsing, not
	// composing.
	Header textproto.MIMEHeader
}

// Compose returns a DSN message.
//
// smtputf8 indicates whether the remote MTA that is receiving the DSN
// supports smtputf8. This influences the message media (sub)types used for the
// DSN.
//
// DKIM signatures are added if DKIM signing is configured for the "from" domain.
func (m *Message) Compose(log *mlog.Log, smtputf8 bool) ([]byte, error) {
	// ../rfc/3462:119
	// ../rfc/3464:377
	// We'll make a multipart/report with 2 or 3 parts:
	// - 1. human-readable explanation;
	// - 2. message/delivery-status;
	// - 3. (optional) original message (either in full, or only headers).

	// todo future: add option to send full message. but only do so if the message is <100kb.
	// todo future: possibly write to a file directly, instead of building up message in memory.

	// If message does not require smtputf8, we are never generating a utf-8 DSN.
	if !m.SMTPUTF8 {
		smtputf8 = false
	}

	// We check for errors once after all the writes.
	msgw := &errWriter{w: &bytes.Buffer{}}

	header := func(k, v string) {
		fmt.Fprintf(msgw, "%s: %s\r\n", k, v)
	}

	line := func(w io.Writer) {
		_, _ = w.Write([]byte("\r\n"))
	}

	// Outer message headers.
	header("From", fmt.Sprintf("<%s>", m.From.XString(smtputf8))) // todo: would be good to have a local ascii-only name for this address.
	header("To", fmt.Sprintf("<%s>", m.To.XString(smtputf8)))     // todo: we could just leave this out if it has utf-8 and remote does not support utf-8.
	header("Subject", m.Subject)
	header("Message-Id", fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8)))
	header("Date", time.Now().Format(message.RFC5322Z))
	header("MIME-Version", "1.0")
	mp := multipart.NewWriter(msgw)
	header("Content-Type", fmt.Sprintf(`multipart/report; report-type="delivery-status"; boundary="%s"`, mp.Boundary()))

	line(msgw)

	// First part, human-readable message.
	msgHdr := textproto.MIMEHeader{}
	if smtputf8 {
		msgHdr.Set("Content-Type", "text/plain; charset=utf-8")
		msgHdr.Set("Content-Transfer-Encoding", "8BIT")
	} else {
		msgHdr.Set("Content-Type", "text/plain")
		msgHdr.Set("Content-Transfer-Encoding", "7BIT")
	}
	msgp, err := mp.CreatePart(msgHdr)
	if err != nil {
		return nil, err
	}
	if _, err := msgp.Write([]byte(strings.ReplaceAll(m.TextBody, "\n", "\r\n"))); err != nil {
		return nil, err
	}

	// Machine-parsable message. ../rfc/3464:455
	statusHdr := textproto.MIMEHeader{}
	if smtputf8 {
		// ../rfc/6533:325
		statusHdr.Set("Content-Type", "message/global-delivery-status")
		statusHdr.Set("Content-Transfer-Encoding", "8BIT")
	} else {
		statusHdr.Set("Content-Type", "message/delivery-status")
		statusHdr.Set("Content-Transfer-Encoding", "7BIT")
	}
	statusp, err := mp.CreatePart(statusHdr)
	if err != nil {
		return nil, err
	}

	// ../rfc/3464:470
	// examples: ../rfc/3464:1855
	// type fields: ../rfc/3464:536 https://www.iana.org/assignments/dsn-types/dsn-types.xhtml

	status := func(k, v string) {
		fmt.Fprintf(statusp, "%s: %s\r\n", k, v)
	}

	// Per-message fields first. ../rfc/3464:575
	// todo future: once we support the smtp dsn extension, the envid should be saved/set as OriginalEnvelopeID. ../rfc/3464:583 ../rfc/3461:1139
	if m.OriginalEnvelopeID != "" {
		status("Original-Envelope-ID", m.OriginalEnvelopeID)
	}
	status("Reporting-MTA", "dns; "+m.ReportingMTA) // ../rfc/3464:628
	if m.DSNGateway != "" {
		// ../rfc/3464:714
		status("DSN-Gateway", "dns; "+m.DSNGateway)
	}
	if !m.ReceivedFromMTA.IsZero() {
		// ../rfc/3464:735
		status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
	}
	status("Arrival-Date", m.ArrivalDate.Format(message.RFC5322Z)) // ../rfc/3464:758

	// Then per-recipient fields. ../rfc/3464:769
	// todo: should also handle other address types. at least recognize "unknown". Probably just store this field. ../rfc/3464:819
	addrType := "rfc822;" // ../rfc/3464:514
	if smtputf8 {
		addrType = "utf-8;" // ../rfc/6533:250
	}
	if len(m.Recipients) == 0 {
		return nil, fmt.Errorf("missing per-recipient fields")
	}
	for _, r := range m.Recipients {
		line(statusp)
		if !r.OriginalRecipient.IsZero() {
			// ../rfc/3464:807
			status("Original-Recipient", addrType+r.OriginalRecipient.DSNString(smtputf8))
		}
		status("Final-Recipient", addrType+r.FinalRecipient.DSNString(smtputf8)) // ../rfc/3464:829
		status("Action", string(r.Action))                                       // ../rfc/3464:879
		st := r.Status
		if st == "" {
			// ../rfc/3464:944
			// Making up a status code is not great, but the field is required. We could simply
			// require the caller to make one up...
			switch r.Action {
			case Delayed:
				st = "4.0.0"
			case Failed:
				st = "5.0.0"
			default:
				st = "2.0.0"
			}
		}
		var rest string
		st, rest = codeLine(st)
		statusLine := st
		if rest != "" {
			statusLine += " (" + rest + ")"
		}
		status("Status", statusLine) // ../rfc/3464:975
		if !r.RemoteMTA.IsZero() {
			// ../rfc/3464:1015
			status("Remote-MTA", fmt.Sprintf("dns;%s (%s)", r.RemoteMTA.Name, smtp.AddressLiteral(r.RemoteMTA.IP)))
		}
		// Presence of Diagnostic-Code indicates the code is from Remote-MTA. ../rfc/3464:1053
		if r.DiagnosticCode != "" {
			diagCode, rest := codeLine(r.DiagnosticCode)
			diagLine := diagCode
			if rest != "" {
				diagLine += " (" + rest + ")"
			}
			// ../rfc/6533:589
			status("Diagnostic-Code", "smtp; "+diagLine)
		}
		if !r.LastAttemptDate.IsZero() {
			status("Last-Attempt-Date", r.LastAttemptDate.Format(message.RFC5322Z)) // ../rfc/3464:1076
		}
		if r.FinalLogID != "" {
			// todo future: think about adding cid as "Final-Log-Id"?
			status("Final-Log-ID", r.FinalLogID) // ../rfc/3464:1098
		}
		if r.WillRetryUntil != nil {
			status("Will-Retry-Until", r.WillRetryUntil.Format(message.RFC5322Z)) // ../rfc/3464:1108
		}
	}

	// We include only the header of the original message.
	// todo: add the textual version of the original message, if it exists and isn't too large.
	if m.Original != nil {
		headers, err := message.ReadHeaders(bufio.NewReader(bytes.NewReader(m.Original)))
		if err != nil && errors.Is(err, message.ErrHeaderSeparator) {
			// Whole data is a header.
			headers = m.Original
		} else if err != nil {
			return nil, err
		} else {
			// This is a whole message. We still only include the headers.
			// todo: include the whole body.
		}

		origHdr := textproto.MIMEHeader{}
		if smtputf8 {
			// ../rfc/6533:431
			// ../rfc/6533:605
			origHdr.Set("Content-Type", "message/global-headers") // ../rfc/6533:625
			origHdr.Set("Content-Transfer-Encoding", "8BIT")
		} else {
			// ../rfc/3462:175
			if m.SMTPUTF8 {
				// ../rfc/6533:480
				origHdr.Set("Content-Type", "text/rfc822-headers; charset=utf-8")
				origHdr.Set("Content-Transfer-Encoding", "BASE64")
			} else {
				origHdr.Set("Content-Type", "text/rfc822-headers")
				origHdr.Set("Content-Transfer-Encoding", "7BIT")
			}
		}
		origp, err := mp.CreatePart(origHdr)
		if err != nil {
			return nil, err
		}

		if !smtputf8 && m.SMTPUTF8 {
			data := base64.StdEncoding.EncodeToString(headers)
			for len(data) > 0 {
				line := data
				n := len(line)
				if n > 78 {
					n = 78
				}
				line, data = data[:n], data[n:]
				if _, err := origp.Write([]byte(line + "\r\n")); err != nil {
					return nil, err
				}
			}
		} else {
			if _, err := origp.Write(headers); err != nil {
				return nil, err
			}
		}
	}

	if err := mp.Close(); err != nil {
		return nil, err
	}

	if msgw.err != nil {
		return nil, err
	}

	data := msgw.w.Bytes()

	fd := m.From.IPDomain.Domain
	confDom, _ := mox.Conf.Domain(fd)
	if len(confDom.DKIM.Sign) > 0 {
		if dkimHeaders, err := dkim.Sign(context.Background(), m.From.Localpart, fd, confDom.DKIM, smtputf8, bytes.NewReader(data)); err != nil {
			log.Errorx("dsn: dkim sign for domain, returning unsigned dsn", err, mlog.Field("domain", fd))
		} else {
			data = append([]byte(dkimHeaders), data...)
		}
	}

	return data, nil
}

type errWriter struct {
	w   *bytes.Buffer
	err error
}

func (w *errWriter) Write(buf []byte) (int, error) {
	if w.err != nil {
		return -1, w.err
	}
	n, err := w.w.Write(buf)
	w.err = err
	return n, err
}

// split a line into enhanced status code and rest.
func codeLine(s string) (string, string) {
	t := strings.SplitN(s, " ", 2)
	l := strings.Split(t[0], ".")
	if len(l) != 3 {
		return "", s
	}
	for i, e := range l {
		_, err := strconv.ParseInt(e, 10, 32)
		if err != nil {
			return "", s
		}
		if i == 0 && len(e) != 1 {
			return "", s
		}
	}

	var rest string
	if len(t) == 2 {
		rest = t[1]
	}
	return t[0], rest
}

// HasCode returns whether line starts with an enhanced SMTP status code.
func HasCode(line string) bool {
	// ../rfc/3464:986
	ecode, _ := codeLine(line)
	return ecode != ""
}
