// Package relay resolves MX records and delivers mail directly to recipient servers
// (no smart-host relay), mirroring email_server/email_relay.py.
package relay

import (
	"crypto/tls"
	"fmt"
	"net"
	"net/smtp"
	"strconv"
	"strings"
	"time"

	"gopkg.in/ini.v1"
	"mailgoserver/internal/db"
	"mailgoserver/internal/mailstore"
	"mailgoserver/internal/toolbox"
)

const mxPort = 25

// Result mirrors one entry of email_relay's per-recipient results list.
type Result struct {
	Recipient      string
	RecipientType  string // "to" | "cc" | "bcc"
	Status         string // "success" | "failed"
	ErrorCode      string
	ErrorMessage   string
	ServerResponse string
	// Quarantined is true for a local delivery that landed in Junk rather than INBOX.
	// Only ever set by smtpserver's local-delivery path (always false for outbound
	// relay results) — it's the signal Data() uses to keep this message's body in the
	// admin log despite content-logging otherwise being off by default, so a spam/
	// malicious report can actually be reviewed.
	Quarantined bool
}

type Relay struct {
	DB      *db.DB
	Timeout time.Duration
	// Hostname is used as the outbound EHLO/HELO identity, mirroring
	// email_relay.py's self.hostname (helo_hostname, falling back to hostname), and as
	// the domain part of SendBounce's mailer-daemon@ From address.
	Hostname string
	Logger   *toolbox.Logger

	// Mailstore backs SendBounce's local-delivery shortcut (straight into a bounce
	// recipient's own INBOX when they're a mailbox this server hosts, no SMTP
	// round-trip needed). Set by main.go once mailstore.New has run — relay.New runs
	// before that, so this is assigned afterward rather than threaded through the
	// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
	Mailstore *mailstore.Store
}

// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
// relay_timeout from the wrong [Server] section and so always falls back to its
// hardcoded default of 30s), this reads the value from [Relay] as the config file's
// own comments say it should — the approved bug fix.
func New(database *db.DB, cfg *ini.File, logger *toolbox.Logger) *Relay {
	timeoutSecs := cfg.Section("Relay").Key("RELAY_TIMEOUT").MustInt(30)
	hostname := cfg.Section("Server").Key("helo_hostname").String()
	if hostname == "" {
		hostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
	}
	return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger}
}

// prepareEmailForRecipient mirrors email_relay._prepare_email_for_recipient: strips any
// Bcc header line from the header block only, leaves the body untouched.
func prepareEmailForRecipient(content string) string {
	idx := strings.Index(content, "\r\n\r\n")
	sep := "\r\n\r\n"
	if idx < 0 {
		idx = strings.Index(content, "\n\n")
		sep = "\n\n"
		if idx < 0 {
			idx = len(content)
			sep = "\r\n\r\n"
		}
	}
	headerBlock, body := content[:idx], content[idx+len(sep):]

	var kept []string
	for _, line := range strings.Split(headerBlock, "\n") {
		trimmed := strings.TrimRight(line, "\r")
		if strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "bcc:") {
			continue
		}
		kept = append(kept, trimmed)
	}
	return strings.Join(kept, "\r\n") + "\r\n\r\n" + body
}

// RelayEmailAsync mirrors email_relay.relay_email_async: TO/CC recipients are grouped
// by domain and delivered in one shared SMTP transaction per domain; each BCC recipient
// gets its own transaction. MX hosts are tried once each, in preference order, with
// opportunistic STARTTLS.
func (r *Relay) RelayEmailAsync(mailFrom string, rcptTos []string, content string, recipientTypes []string) []Result {
	if len(recipientTypes) != len(rcptTos) {
		recipientTypes = make([]string, len(rcptTos))
		for i := range recipientTypes {
			recipientTypes[i] = "to"
		}
	}

	type group struct{ to, cc []string }
	domainGroups := map[string]*group{}
	var bccList []string

	for i, rcpt := range rcptTos {
		typ := recipientTypes[i]
		if typ == "bcc" {
			bccList = append(bccList, rcpt)
			continue
		}
		domain := domainOf(rcpt)
		g, ok := domainGroups[domain]
		if !ok {
			g = &group{}
			domainGroups[domain] = g
		}
		if typ == "cc" {
			g.cc = append(g.cc, rcpt)
		} else {
			g.to = append(g.to, rcpt)
		}
	}

	var results []Result
	prepared := prepareEmailForRecipient(content)

	for domain, g := range domainGroups {
		all := append(append([]string{}, g.to...), g.cc...)
		if len(all) == 0 {
			continue
		}
		status, serverResp, errCode, errMsg := r.deliverToDomain(domain, mailFrom, all, prepared)
		for _, rcpt := range g.to {
			results = append(results, Result{Recipient: rcpt, RecipientType: "to", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
		}
		for _, rcpt := range g.cc {
			results = append(results, Result{Recipient: rcpt, RecipientType: "cc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
		}
	}

	for _, bcc := range bccList {
		status, serverResp, errCode, errMsg := r.deliverToDomain(domainOf(bcc), mailFrom, []string{bcc}, prepared)
		results = append(results, Result{Recipient: bcc, RecipientType: "bcc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
	}

	return results
}

func domainOf(address string) string {
	if i := strings.LastIndex(address, "@"); i >= 0 {
		return strings.ToLower(address[i+1:])
	}
	return ""
}

// deliverToDomain resolves MX hosts for domain and tries each in preference order once,
// mirroring the MX-iteration loop in relay_email_async.
func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string) {
	mxRecords, err := net.LookupMX(domain)
	if err != nil || len(mxRecords) == 0 {
		return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err)
	}

	var lastErr error
	for _, mx := range mxRecords {
		host := strings.TrimSuffix(mx.Host, ".")
		resp, err := r.trySend(host, mailFrom, rcpts, content)
		if err == nil {
			return "success", resp, "", ""
		}
		lastErr = err
		r.Logger.Warning("Relay to %s (%s) failed: %v", host, domain, err)
	}
	return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
}

func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) {
	conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout)
	if err != nil {
		return "", err
	}
	conn.SetDeadline(time.Now().Add(r.Timeout))
	defer conn.Close()

	c, err := smtp.NewClient(conn, host)
	if err != nil {
		return "", err
	}
	defer c.Close()

	if err := c.Hello(r.Hostname); err != nil {
		return "", err
	}

	// Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise —
	// mirrors relay_email_async's "if starttls in extensions" check with no hard
	// requirement, and no strict certificate verification since arbitrary receiving
	// MTAs commonly present certs that don't chain cleanly (matches the Python code,
	// which never configures certificate verification for this opportunistic hop).
	if ok, _ := c.Extension("STARTTLS"); ok {
		tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: true}
		if err := c.StartTLS(tlsConfig); err != nil {
			return "", err
		}
	}

	if err := c.Mail(mailFrom); err != nil {
		return "", err
	}
	for _, rcpt := range rcpts {
		if err := c.Rcpt(rcpt); err != nil {
			return "", err
		}
	}
	w, err := c.Data()
	if err != nil {
		return "", err
	}
	if _, err := w.Write([]byte(content)); err != nil {
		return "", err
	}
	if err := w.Close(); err != nil {
		return "", err
	}
	_ = c.Quit()
	return "250 OK", nil
}
