// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license

package email

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"regexp"
	"strings"
	"time"

	"github.com/ergochat/ergo/irc/custime"
	"github.com/ergochat/ergo/irc/smtp"
	"github.com/ergochat/ergo/irc/utils"
)

var (
	ErrBlacklistedAddress = errors.New("Email address is blacklisted")
	ErrInvalidAddress     = errors.New("Email address is invalid")
	ErrNoMXRecord         = errors.New("Couldn't resolve MX record")
)

type BlacklistSyntax uint

const (
	BlacklistSyntaxGlob BlacklistSyntax = iota
	BlacklistSyntaxRegexp
)

func blacklistSyntaxFromString(status string) (BlacklistSyntax, error) {
	switch strings.ToLower(status) {
	case "glob", "":
		return BlacklistSyntaxGlob, nil
	case "re", "regex", "regexp":
		return BlacklistSyntaxRegexp, nil
	default:
		return BlacklistSyntaxRegexp, fmt.Errorf("Unknown blacklist syntax type `%s`", status)
	}
}

func (bs *BlacklistSyntax) UnmarshalYAML(unmarshal func(interface{}) error) error {
	var orig string
	var err error
	if err = unmarshal(&orig); err != nil {
		return err
	}
	if result, err := blacklistSyntaxFromString(orig); err == nil {
		*bs = result
		return nil
	} else {
		return err
	}
}

type MTAConfig struct {
	Server      string
	Port        int
	Username    string
	Password    string
	ImplicitTLS bool `yaml:"implicit-tls"`
}

type MailtoConfig struct {
	// legacy config format assumed the use of an MTA/smarthost,
	// so server, port, etc. appear directly at top level
	// XXX: see https://github.com/go-yaml/yaml/issues/63
	MTAConfig              `yaml:",inline"`
	Enabled                bool
	Sender                 string
	HeloDomain             string `yaml:"helo-domain"`
	RequireTLS             bool   `yaml:"require-tls"`
	Protocol               string `yaml:"protocol"`
	LocalAddress           string `yaml:"local-address"`
	localAddress           net.Addr
	VerifyMessageSubject   string `yaml:"verify-message-subject"`
	DKIM                   DKIMConfig
	MTAReal                MTAConfig       `yaml:"mta"`
	AddressBlacklist       []string        `yaml:"address-blacklist"`
	AddressBlacklistSyntax BlacklistSyntax `yaml:"address-blacklist-syntax"`
	AddressBlacklistFile   string          `yaml:"address-blacklist-file"`
	blacklistRegexes       []*regexp.Regexp
	Timeout                time.Duration
	PasswordReset          struct {
		Enabled  bool
		Cooldown custime.Duration
		Timeout  custime.Duration
	} `yaml:"password-reset"`
}

func (config *MailtoConfig) compileBlacklistEntry(source string) (re *regexp.Regexp, err error) {
	if config.AddressBlacklistSyntax == BlacklistSyntaxGlob {
		return utils.CompileGlob(source, false)
	} else {
		return regexp.Compile(fmt.Sprintf("^%s$", source))
	}
}

func (config *MailtoConfig) processBlacklistFile(filename string) (result []*regexp.Regexp, err error) {
	f, err := os.Open(filename)
	if err != nil {
		return
	}
	defer f.Close()
	reader := bufio.NewReader(f)
	lineNo := 0
	for {
		line, err := reader.ReadString('\n')
		lineNo++
		line = strings.TrimSpace(line)
		if line != "" && line[0] != '#' {
			if compiled, compileErr := config.compileBlacklistEntry(line); compileErr == nil {
				result = append(result, compiled)
			} else {
				return result, fmt.Errorf("Failed to compile line %d of blacklist-regex-file `%s`: %w", lineNo, line, compileErr)
			}
		}
		switch err {
		case io.EOF:
			return result, nil
		case nil:
			continue
		default:
			return result, err
		}
	}
}

func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
	if config.Sender == "" {
		return errors.New("Invalid mailto sender address")
	}

	// check for MTA config fields at top level,
	// copy to MTAReal if present
	if config.Server != "" && config.MTAReal.Server == "" {
		config.MTAReal = config.MTAConfig
	}

	if config.HeloDomain == "" {
		config.HeloDomain = heloDomain
	}

	if config.AddressBlacklistFile != "" {
		config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile)
		if err != nil {
			return err
		}
	} else if len(config.AddressBlacklist) != 0 {
		config.blacklistRegexes = make([]*regexp.Regexp, 0, len(config.AddressBlacklist))
		for _, reg := range config.AddressBlacklist {
			compiled, err := config.compileBlacklistEntry(reg)
			if err != nil {
				return err
			}
			config.blacklistRegexes = append(config.blacklistRegexes, compiled)
		}
	}

	config.Protocol = strings.ToLower(config.Protocol)
	if config.Protocol == "" {
		config.Protocol = "tcp"
	}
	if !(config.Protocol == "tcp" || config.Protocol == "tcp4" || config.Protocol == "tcp6") {
		return fmt.Errorf("Invalid protocol for email sending: `%s`", config.Protocol)
	}

	if config.LocalAddress != "" {
		ipAddr := net.ParseIP(config.LocalAddress)
		if ipAddr == nil {
			return fmt.Errorf("Could not parse local-address for email sending: `%s`", config.LocalAddress)
		}
		config.localAddress = &net.TCPAddr{
			IP:   ipAddr,
			Port: 0,
		}
	}

	if config.MTAConfig.Server != "" {
		// smarthost, nothing more to validate
		return nil
	}

	return config.DKIM.Postprocess()
}

// are we sending email directly, as opposed to deferring to an MTA?
func (config *MailtoConfig) DirectSendingEnabled() bool {
	return config.MTAReal.Server == ""
}

// get the preferred MX record hostname, "" on error
func lookupMX(domain string) (server string) {
	var minPref uint16
	results, err := net.LookupMX(domain)
	if err != nil {
		return
	}
	for _, result := range results {
		if minPref == 0 || result.Pref < minPref {
			server, minPref = result.Host, result.Pref
		}
	}
	return
}

func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.Buffer) {
	fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
	fmt.Fprintf(&message, "To: %s\r\n", recipient)
	dkimDomain := config.DKIM.Domain
	if dkimDomain != "" {
		fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
	} else {
		// #2108: send Message-ID even if dkim is not enabled
		fmt.Fprintf(&message, "Message-ID: <%s-%s>\r\n", utils.GenerateSecretKey(), config.Sender)
	}
	fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
	fmt.Fprintf(&message, "Subject: %s\r\n", subject)
	message.WriteString("\r\n") // blank line: end headers, begin message body
	return message
}

func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
	recipientLower := strings.ToLower(recipient)
	for _, reg := range config.blacklistRegexes {
		if reg.MatchString(recipientLower) {
			return ErrBlacklistedAddress
		}
	}

	if config.DKIM.Enabled() {
		msg, err = DKIMSign(msg, config.DKIM)
		if err != nil {
			return
		}
	}

	var addr string
	var auth smtp.Auth
	var implicitTLS bool
	if !config.DirectSendingEnabled() {
		addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port)
		if config.MTAReal.Username != "" && config.MTAReal.Password != "" {
			auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server)
		}
		implicitTLS = config.MTAReal.ImplicitTLS
	} else {
		idx := strings.IndexByte(recipient, '@')
		if idx == -1 {
			return ErrInvalidAddress
		}
		mx := lookupMX(recipient[idx+1:])
		if mx == "" {
			return ErrNoMXRecord
		}
		addr = fmt.Sprintf("%s:smtp", mx)
	}

	return smtp.SendMail(
		addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
		config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
	)
}
