// Package outbound implements external (outbound) email delivery.
//
// Messages queued for external recipients are stored in the outbound_messages
// table and delivered by the Manager's background worker: MX lookup, SMTP
// transaction over port 25 with opportunistic STARTTLS, exponential backoff
// retries, permanent-failure bounces and DKIM signing. A smarthost relay can
// be configured for servers whose own IP is blocklisted (e.g. PBL).
package outbound

import (
	"context"
	"crypto/tls"
	"encoding/base64"
	"errors"
	"fmt"
	"net"
	"net/textproto"
	"sort"
	"strconv"
	"strings"
	"time"
)

// DeliveryError wraps an SMTP delivery failure and records whether it is
// permanent (5xx / NXDOMAIN / invalid address) or temporary (4xx / network /
// timeout). Temporary failures are retried by the queue worker.
type DeliveryError struct {
	Permanent bool
	Code      int
	Msg       string
}

func (e *DeliveryError) Error() string {
	if e.Code > 0 {
		return fmt.Sprintf("%d %s", e.Code, e.Msg)
	}
	return e.Msg
}

// newTempError creates a temporary delivery error.
func newTempError(format string, args ...interface{}) *DeliveryError {
	return &DeliveryError{Permanent: false, Msg: fmt.Sprintf(format, args...)}
}

// newPermError creates a permanent delivery error.
func newPermError(format string, args ...interface{}) *DeliveryError {
	return &DeliveryError{Permanent: true, Msg: fmt.Sprintf(format, args...)}
}

// RelayConfig describes a smarthost through which all external mail is sent.
type RelayConfig struct {
	Host        string
	Port        int    // 465 = implicit TLS; other ports may use STARTTLS
	Username    string // AUTH PLAIN credentials (empty = no authentication)
	Password    string
	StartTLS    bool // use STARTTLS on non-465 ports
	TLSInsecure bool // skip certificate verification (test-only, credentials leak risk)
}

// Mailer performs direct MX delivery (or smarthost relay) of a single message.
type Mailer struct {
	Hostname       string // EHLO hostname presented to remote servers
	Port           int    // destination port, 0 means the default SMTP port 25
	Relay          *RelayConfig
	IPFamily       string // "ipv4" (default), "ipv6" or "auto"
	SourceIP       string // optional source address to bind (e.g. a static IPv6)
	ConnectTimeout time.Duration
}

// NewMailer creates a Mailer with the given EHLO hostname and connect timeout.
func NewMailer(hostname string, connectTimeout time.Duration) *Mailer {
	if hostname == "" {
		hostname = "localhost"
	}
	return &Mailer{Hostname: hostname, ConnectTimeout: connectTimeout}
}

// port returns the destination port, defaulting to 25.
func (m *Mailer) port() int {
	if m.Port == 0 {
		return 25
	}
	return m.Port
}

// Deliver sends one message to one recipient. When a relay is configured the
// message goes through the smarthost; otherwise the recipient domain's MX is
// used. It returns the final SMTP response text on success and a
// *DeliveryError on failure.
func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
	if m.Relay != nil && m.Relay.Host != "" {
		return m.deliverViaRelay(from, to, data)
	}

	at := strings.LastIndex(to, "@")
	if at < 0 || at == len(to)-1 {
		return "", newPermError("invalid recipient address: %s", to)
	}
	domain := strings.ToLower(strings.TrimSpace(to[at+1:]))

	mxHosts, err := lookupMX(domain, m.IPFamily)
	if err != nil {
		var de *DeliveryError
		if errors.As(err, &de) {
			return "", de
		}
		return "", newTempError("MX lookup failed for %s: %v", domain, err)
	}

	var lastErr *DeliveryError
	for _, host := range mxHosts {
		resp, err := m.deliverToHost(host, from, to, data)
		if err == nil {
			return resp, nil
		}
		var de *DeliveryError
		if errors.As(err, &de) {
			lastErr = de
			// A permanent failure from one MX applies to the whole message,
			// do not try other MX hosts.
			if de.Permanent {
				return "", de
			}
			continue
		}
		lastErr = newTempError("delivery to %s failed: %v", host, err)
	}
	if lastErr == nil {
		lastErr = newTempError("no MX hosts available for %s", domain)
	}
	return "", lastErr
}

// deliverViaRelay sends the message through the configured smarthost.
// The relay carries AUTH credentials, so its TLS certificate is verified
// unless RelayTLSInsecure is explicitly enabled.
func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
	port := m.Relay.Port
	if port == 0 {
		port = 587
	}
	implicitTLS := port == 465
	return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
		m.Relay.StartTLS && !implicitTLS,
		m.Relay.Username, m.Relay.Password, from, to, data,
		m.Relay.TLSInsecure)
}

// smtpClient wraps a textproto connection to a remote SMTP server.
type smtpClient struct {
	conn net.Conn
	txt  *textproto.Conn
	host string
	exts map[string]string // advertised EHLO extensions (upper-case key -> params)
}

func (c *smtpClient) Close() {
	if c.txt != nil {
		_ = c.txt.Close()
	}
}

// cmd sends a command and expects the given reply codes, returning the
// response text. Codes other than expected are returned as a DeliveryError.
func (c *smtpClient) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
	if err := c.txt.PrintfLine(format, args...); err != nil {
		return 0, "", newTempError("write to %s failed: %v", c.host, err)
	}
	code, msg, err := c.txt.ReadResponse(expectCode)
	if err != nil {
		return code, msg, classifyResponse(err, msg)
	}
	return code, msg, nil
}

// classifyResponse converts a textproto error (wrong reply code) into a
// DeliveryError, keeping the actual SMTP code and text.
func classifyResponse(err error, fallback string) *DeliveryError {
	var protoErr *textproto.Error
	if errors.As(err, &protoErr) {
		return &DeliveryError{
			Permanent: protoErr.Code >= 500,
			Code:      protoErr.Code,
			Msg:       protoErr.Msg,
		}
	}
	if fallback != "" {
		return newTempError("%s", fallback)
	}
	return newTempError("%v", err)
}

// hello sends EHLO and records the advertised extensions. If EHLO fails it
// falls back to HELO for very old servers.
func (c *smtpClient) hello(hostname string) error {
	if err := c.txt.PrintfLine("EHLO %s", hostname); err != nil {
		return newTempError("write EHLO to %s failed: %v", c.host, err)
	}
	code, msg, err := c.txt.ReadResponse(250)
	if err != nil {
		// Fall back to HELO.
		if err := c.txt.PrintfLine("HELO %s", hostname); err != nil {
			return newTempError("write HELO to %s failed: %v", c.host, err)
		}
		code, msg, err = c.txt.ReadResponse(250)
		if err != nil {
			return classifyResponse(err, msg)
		}
		return nil
	}
	_ = code
	c.exts = map[string]string{}
	for _, line := range strings.Split(msg, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, " ", 2)
		key := strings.ToUpper(parts[0])
		val := ""
		if len(parts) == 2 {
			val = parts[1]
		}
		c.exts[key] = val
	}
	return nil
}

// authPlain performs AUTH PLAIN with the initial-response form, falling back
// to the two-step form when the server asks for credentials separately.
func (c *smtpClient) authPlain(username, password string) error {
	b64 := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password))
	code, msg, err := c.cmd(235, "AUTH PLAIN %s", b64)
	if err != nil {
		if code == 334 {
			_, _, err = c.cmd(235, "%s", b64)
		}
		if err != nil {
			return err
		}
	}
	_ = msg
	return nil
}

// deliverToHost performs a full SMTP transaction with a single MX host.
// Direct MX delivery is opportunistic TLS: certificates are not verified
// because most MX certificates cannot be validated over a cold connection.
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
	return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data, true)
}

// smtpTransaction performs one complete SMTP session: connect, greeting,
// optional implicit TLS / STARTTLS, optional AUTH PLAIN, MAIL/RCPT/DATA/QUIT.
// tlsInsecure 控制 TLS 握手时是否跳过证书验证：直投 MX 用 true（机会式
// TLS），relay 用配置值（默认 false，保护中继凭据）。
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte, tlsInsecure bool) (string, error) {
	addr := net.JoinHostPort(host, strconv.Itoa(port))

	ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
	defer cancel()

	conn, err := m.dialSMTP(ctx, addr)
	if err != nil {
		return "", newTempError("connect to %s failed: %v", addr, err)
	}

	c := &smtpClient{conn: conn, txt: textproto.NewConn(conn), host: host}
	defer c.Close()

	// Read greeting (expect 220).
	if _, msg, err := c.txt.ReadResponse(220); err != nil {
		return "", classifyResponse(err, msg)
	}

	tlsServerName := host
	if ip := net.ParseIP(host); ip != nil {
		// IP literal 同样作为 ServerName：证书验证模式下校验其 IP SAN
		tlsServerName = ip.String()
	}

	if implicitTLS {
		tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
		if err != nil {
			return "", err
		}
		c.txt = textproto.NewConn(tlsConn)
		if err := c.hello(m.Hostname); err != nil {
			return "", err
		}
	} else {
		if err := c.hello(m.Hostname); err != nil {
			return "", err
		}
		// Opportunistic STARTTLS: only when the server advertises it, unless
		// startTLS is explicitly requested (smarthost), in which case a
		// non-advertising server is an error.
		_, adv := c.exts["STARTTLS"]
		if adv || startTLS {
			if !adv && startTLS {
				return "", newTempError("%s does not advertise STARTTLS", host)
			}
			if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
				return "", err
			}
			tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
			if err != nil {
				return "", err
			}
			c.txt = textproto.NewConn(tlsConn)
			if err := c.hello(m.Hostname); err != nil {
				return "", err
			}
		}
	}

	if username != "" {
		if err := c.authPlain(username, password); err != nil {
			return "", fmt.Errorf("AUTH PLAIN with %s failed: %w", host, err)
		}
	}

	// MAIL FROM with BODY=8BITMIME when the message contains 8-bit bytes and
	// the remote server supports it.
	mailCmd := "MAIL FROM:<%s>"
	if is8Bit(data) {
		if _, ok := c.exts["8BITMIME"]; ok {
			mailCmd = "MAIL FROM:<%s> BODY=8BITMIME"
		} else {
			return "", newPermError("%s does not advertise 8BITMIME and the message contains 8-bit data", host)
		}
	}
	if _, _, err := c.cmd(250, mailCmd, from); err != nil {
		return "", err
	}
	if _, _, err := c.cmd(250, "RCPT TO:<%s>", to); err != nil {
		return "", err
	}
	if _, _, err := c.cmd(354, "DATA"); err != nil {
		return "", err
	}

	// Write the message body with dot-stuffing.
	dw := c.txt.DotWriter()
	if _, err := dw.Write(data); err != nil {
		_ = dw.Close()
		return "", newTempError("writing message data to %s failed: %v", host, err)
	}
	if err := dw.Close(); err != nil {
		return "", newTempError("finalizing message data to %s failed: %v", host, err)
	}

	code, msg, err := c.txt.ReadResponse(250)
	if err != nil {
		return "", classifyResponse(err, msg)
	}

	// Best-effort QUIT.
	_ = c.txt.PrintfLine("QUIT")
	_, _, _ = c.txt.ReadResponse(221)

	return fmt.Sprintf("%d %s", code, msg), nil
}

// tlsClientHandshake upgrades a plain connection to TLS.
// Direct MX delivery passes insecure=true (opportunistic TLS: remote MX
// certificates often cannot be verified). Relays with AUTH credentials must
// pass insecure=false so the connection cannot be MITM'd.
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string, insecure bool) (net.Conn, error) {
	tlsConn := tls.Client(conn, &tls.Config{
		ServerName:         serverName,
		InsecureSkipVerify: insecure,
	})
	if err := tlsConn.HandshakeContext(ctx); err != nil {
		return nil, newTempError("TLS handshake with %s failed: %v", host, err)
	}
	return tlsConn, nil
}

// dialSMTP connects to a remote SMTP server, honoring the configured IP
// family and optional source address binding.
//
// The default is IPv4-only: many receiving systems (e.g. Gmail) reject mail
// from IPv6 addresses without PTR records, and the IPv4 address of a mail
// host usually has a forward-confirmed PTR and a matching SPF entry. Switch
// IPFamily to "ipv6"/"auto" after the ISP has configured a PTR for the
// source address and SourceIP binds the connection to that static address.
func (m *Mailer) dialSMTP(ctx context.Context, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, err
	}

	network := "tcp4"
	if ip := net.ParseIP(host); ip != nil {
		// Literal destination: pick the matching family.
		if ip.To4() == nil {
			network = "tcp6"
		}
	} else {
		switch strings.ToLower(m.IPFamily) {
		case "ipv6":
			network = "tcp6"
		case "auto":
			network = "tcp"
		default: // "ipv4" and anything unrecognized
			network = "tcp4"
		}
	}

	dialer := &net.Dialer{Timeout: m.ConnectTimeout}
	if m.SourceIP != "" {
		if ip := net.ParseIP(m.SourceIP); ip != nil {
			dialer.LocalAddr = &net.TCPAddr{IP: ip}
		}
	}
	return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
}

// is8Bit reports whether the data contains any byte >= 0x80.
func is8Bit(data []byte) bool {
	for _, b := range data {
		if b >= 0x80 {
			return true
		}
	}
	return false
}

// lookupMX resolves the MX hosts for a domain, sorted by preference.
// Per RFC 5321 section 5.1, when no MX record exists the domain itself is
// used as an implicit MX with preference 0. ipFamily controls the ordering
// of the A/AAAA fallback ("ipv6" puts IPv6 first, otherwise IPv4 first).
func lookupMX(domain, ipFamily string) ([]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
	if err != nil {
		var dnsErr *net.DNSError
		if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
			return nil, newPermError("domain does not exist: %s", domain)
		}
		return nil, err
	}

	if len(mxs) == 0 {
		// Implicit MX: fall back to the domain's A/AAAA records.
		ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
		if err != nil {
			return nil, err
		}
		var hosts []string
		var v6 []string
		for _, ip := range ips {
			if ip.IP.To4() != nil {
				hosts = append(hosts, ip.IP.String())
			} else {
				v6 = append(v6, ip.IP.String())
			}
		}
		if strings.EqualFold(ipFamily, "ipv6") {
			hosts = append(v6, hosts...)
		} else {
			hosts = append(hosts, v6...)
		}
		if len(hosts) == 0 {
			return nil, fmt.Errorf("no MX or A records for %s", domain)
		}
		return hosts, nil
	}

	sort.Slice(mxs, func(i, j int) bool { return mxs[i].Pref < mxs[j].Pref })
	hosts := make([]string, 0, len(mxs))
	for _, mx := range mxs {
		h := strings.TrimSuffix(mx.Host, ".")
		if h != "" {
			hosts = append(hosts, h)
		}
	}
	if len(hosts) == 0 {
		return nil, fmt.Errorf("no usable MX hosts for %s", domain)
	}
	return hosts, nil
}
