// Copyright (C) 2019-2023 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

// Package smtp provides supports for sending emails
package smtp

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"html/template"
	"path/filepath"
	"sync"
	"time"

	"github.com/rs/xid"
	"github.com/wneessen/go-mail"

	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
	"github.com/drakkan/sftpgo/v2/internal/kms"
	"github.com/drakkan/sftpgo/v2/internal/logger"
	"github.com/drakkan/sftpgo/v2/internal/util"
	"github.com/drakkan/sftpgo/v2/internal/version"
)

const (
	logSender = "smtp"
)

// EmailContentType defines the support content types for email body
type EmailContentType int

// Supported email body content type
const (
	EmailContentTypeTextPlain EmailContentType = iota
	EmailContentTypeTextHTML
)

const (
	templateEmailDir           = "email"
	templatePasswordReset      = "reset-password.html"
	templatePasswordExpiration = "password-expiration.html"
	dialTimeout                = 10 * time.Second
)

var (
	config         = &activeConfig{}
	initialConfig  *Config
	emailTemplates = make(map[string]*template.Template)
)

type activeConfig struct {
	sync.RWMutex
	config *Config
}

func (c *activeConfig) isEnabled() bool {
	c.RLock()
	defer c.RUnlock()

	return c.config != nil && c.config.Host != ""
}

func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
	var config *Config
	if cfg != nil {
		config = &Config{
			Host:       cfg.Host,
			Port:       cfg.Port,
			From:       cfg.From,
			User:       cfg.User,
			Password:   cfg.Password.GetPayload(),
			AuthType:   cfg.AuthType,
			Encryption: cfg.Encryption,
			Domain:     cfg.Domain,
			Debug:      cfg.Debug,
			OAuth2: OAuth2Config{
				Provider:     cfg.OAuth2.Provider,
				Tenant:       cfg.OAuth2.Tenant,
				ClientID:     cfg.OAuth2.ClientID,
				ClientSecret: cfg.OAuth2.ClientSecret.GetPayload(),
				RefreshToken: cfg.OAuth2.RefreshToken.GetPayload(),
			},
		}
		config.OAuth2.initialize()
	}

	c.Lock()
	defer c.Unlock()

	if config != nil && config.Host != "" {
		if c.config != nil && c.config.isEqual(config) {
			return
		}
		c.config = config
		logger.Info(logSender, "", "activated new config, server %s:%d", c.config.Host, c.config.Port)
	} else {
		logger.Debug(logSender, "", "activating initial config")
		c.config = initialConfig
		if c.config == nil || c.config.Host == "" {
			logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
		}
	}
}

func (c *activeConfig) getSMTPClientAndMsg(to, bcc []string, subject, body string, contentType EmailContentType,
	attachments ...*mail.File,
) (*mail.Client, *mail.Msg, error) {
	c.RLock()
	defer c.RUnlock()

	if c.config == nil || c.config.Host == "" {
		return nil, nil, errors.New("smtp: not configured")
	}

	return c.config.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
}

func (c *activeConfig) sendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
	client, msg, err := c.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
	if err != nil {
		return err
	}

	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
	defer cancelFn()

	return client.DialAndSendWithContext(ctx, msg)
}

// IsEnabled returns true if an SMTP server is configured
func IsEnabled() bool {
	return config.isEnabled()
}

// Activate sets the specified config as active
func Activate(c *dataprovider.SMTPConfigs) {
	config.Set(c)
}

// Config defines the SMTP configuration to use to send emails
type Config struct {
	// Location of SMTP email server. Leavy empty to disable email sending capabilities
	Host string `json:"host" mapstructure:"host"`
	// Port of SMTP email server
	Port int `json:"port" mapstructure:"port"`
	// From address, for example "SFTPGo <sftpgo@example.com>".
	// Many SMTP servers reject emails without a `From` header so, if not set,
	// SFTPGo will try to use the username as fallback, this may or may not be appropriate
	From string `json:"from" mapstructure:"from"`
	// SMTP username
	User string `json:"user" mapstructure:"user"`
	// SMTP password. Leaving both username and password empty the SMTP authentication
	// will be disabled
	Password string `json:"password" mapstructure:"password"`
	// 0 Plain
	// 1 Login
	// 2 CRAM-MD5
	// 3 OAuth2
	AuthType int `json:"auth_type" mapstructure:"auth_type"`
	// 0 no encryption
	// 1 TLS
	// 2 start TLS
	Encryption int `json:"encryption" mapstructure:"encryption"`
	// Domain to use for HELO command, if empty localhost will be used
	Domain string `json:"domain" mapstructure:"domain"`
	// Path to the email templates. This can be an absolute path or a path relative to the config dir.
	// Templates are searched within a subdirectory named "email" in the specified path
	TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
	// Set to 1 to enable debug logs
	Debug int `json:"debug" mapstructure:"debug"`
	// OAuth2 related settings
	OAuth2 OAuth2Config `json:"oauth2" mapstructure:"oauth2"`
}

func (c *Config) isEqual(other *Config) bool {
	if c.Host != other.Host {
		return false
	}
	if c.Port != other.Port {
		return false
	}
	if c.From != other.From {
		return false
	}
	if c.User != other.User {
		return false
	}
	if c.Password != other.Password {
		return false
	}
	if c.AuthType != other.AuthType {
		return false
	}
	if c.Encryption != other.Encryption {
		return false
	}
	if c.Domain != other.Domain {
		return false
	}
	if c.Debug != other.Debug {
		return false
	}
	return c.OAuth2.isEqual(&other.OAuth2)
}

func (c *Config) validate() error {
	if c.Port <= 0 || c.Port > 65535 {
		return fmt.Errorf("smtp: invalid port %d", c.Port)
	}
	if c.AuthType < 0 || c.AuthType > 3 {
		return fmt.Errorf("smtp: invalid auth type %d", c.AuthType)
	}
	if c.Encryption < 0 || c.Encryption > 2 {
		return fmt.Errorf("smtp: invalid encryption %d", c.Encryption)
	}
	if c.From == "" && c.User == "" {
		return errors.New(`smtp: from address and user cannot both be empty`)
	}
	if c.AuthType == 3 {
		return c.OAuth2.Validate()
	}
	return nil
}

func (c *Config) loadTemplates(configDir string) error {
	if c.TemplatesPath == "" {
		logger.Debug(logSender, "", "templates path empty, using default")
		c.TemplatesPath = "templates"
	}
	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
	if templatesPath == "" {
		return fmt.Errorf("smtp: invalid templates path %q", templatesPath)
	}
	loadTemplates(filepath.Join(templatesPath, templateEmailDir))
	return nil
}

// Initialize initialized and validates the SMTP configuration
func (c *Config) Initialize(configDir string, isService bool) error {
	if !isService && c.Host == "" {
		if err := loadConfigFromProvider(); err != nil {
			return err
		}
		if !config.isEnabled() {
			return nil
		}
		return c.loadTemplates(configDir)
	}
	if err := c.loadTemplates(configDir); err != nil {
		return err
	}
	if c.Host == "" {
		return loadConfigFromProvider()
	}
	if err := c.validate(); err != nil {
		return err
	}
	initialConfig = c
	config.Set(nil)
	logger.Debug(logSender, "", "configuration successfully initialized, host: %q, port: %d, username: %q, auth: %d, encryption: %d, helo: %q",
		c.Host, c.Port, c.User, c.AuthType, c.Encryption, c.Domain)
	return loadConfigFromProvider()
}

func (c *Config) getMailClientOptions() []mail.Option {
	options := []mail.Option{mail.WithPort(c.Port), mail.WithoutNoop()}

	switch c.Encryption {
	case 1:
		options = append(options, mail.WithSSL())
	case 2:
		options = append(options, mail.WithTLSPolicy(mail.TLSMandatory))
	default:
		options = append(options, mail.WithTLSPolicy(mail.NoTLS))
	}
	if c.User != "" {
		options = append(options, mail.WithUsername(c.User))
	}
	if c.Password != "" {
		options = append(options, mail.WithPassword(c.Password))
	}
	if c.User != "" || c.Password != "" {
		switch c.AuthType {
		case 1:
			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthLogin))
		case 2:
			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
		case 3:
			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthXOAUTH2))
		default:
			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthPlain))
		}
	}
	if c.Domain != "" {
		options = append(options, mail.WithHELO(c.Domain))
	}
	if c.Debug > 0 {
		options = append(options,
			mail.WithLogger(&logger.MailAdapter{
				ConnectionID: xid.New().String(),
			}),
			mail.WithDebugLog())
	}
	return options
}

func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, contentType EmailContentType,
	attachments ...*mail.File) (*mail.Client, *mail.Msg, error) {
	version := version.Get()
	msg := mail.NewMsg()
	msg.SetUserAgent(fmt.Sprintf("SFTPGo-%s-%s", version.Version, version.CommitHash))

	var from string
	if c.From != "" {
		from = c.From
	} else {
		from = c.User
	}
	if err := msg.From(from); err != nil {
		return nil, nil, fmt.Errorf("invalid from address: %w", err)
	}
	if err := msg.To(to...); err != nil {
		return nil, nil, err
	}
	if len(bcc) > 0 {
		if err := msg.Bcc(bcc...); err != nil {
			return nil, nil, err
		}
	}
	msg.Subject(subject)
	msg.SetDate()
	msg.SetMessageID()
	msg.SetAttachements(attachments)

	switch contentType {
	case EmailContentTypeTextPlain:
		msg.SetBodyString(mail.TypeTextPlain, body)
	case EmailContentTypeTextHTML:
		msg.SetBodyString(mail.TypeTextHTML, body)
	default:
		return nil, nil, fmt.Errorf("smtp: unsupported body content type %v", contentType)
	}

	client, err := mail.NewClient(c.Host, c.getMailClientOptions()...)
	if err != nil {
		return nil, nil, fmt.Errorf("unable to create mail client: %w", err)
	}
	if c.AuthType == 3 {
		token, err := c.OAuth2.getAccessToken()
		if err != nil {
			return nil, nil, fmt.Errorf("unable to get oauth2 access token: %w", err)
		}
		client.SetPassword(token)
	}
	return client, msg, nil
}

// SendEmail tries to send an email using the specified parameters
func (c *Config) SendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
	client, msg, err := c.getSMTPClientAndMsg(to, bcc, subject, body, contentType, attachments...)
	if err != nil {
		return err
	}
	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
	defer cancelFn()

	return client.DialAndSendWithContext(ctx, msg)
}

func loadTemplates(templatesPath string) {
	logger.Debug(logSender, "", "loading templates from %q", templatesPath)

	passwordResetPath := filepath.Join(templatesPath, templatePasswordReset)
	pwdResetTmpl := util.LoadTemplate(nil, passwordResetPath)
	passwordExpirationPath := filepath.Join(templatesPath, templatePasswordExpiration)
	pwdExpirationTmpl := util.LoadTemplate(nil, passwordExpirationPath)

	emailTemplates[templatePasswordReset] = pwdResetTmpl
	emailTemplates[templatePasswordExpiration] = pwdExpirationTmpl
}

// RenderPasswordResetTemplate executes the password reset template
func RenderPasswordResetTemplate(buf *bytes.Buffer, data any) error {
	if !IsEnabled() {
		return errors.New("smtp: not configured")
	}
	return emailTemplates[templatePasswordReset].Execute(buf, data)
}

// RenderPasswordExpirationTemplate executes the password expiration template
func RenderPasswordExpirationTemplate(buf *bytes.Buffer, data any) error {
	if !IsEnabled() {
		return errors.New("smtp: not configured")
	}
	return emailTemplates[templatePasswordExpiration].Execute(buf, data)
}

// SendEmail tries to send an email using the specified parameters.
func SendEmail(to, bcc []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
	return config.sendEmail(to, bcc, subject, body, contentType, attachments...)
}

// ReloadProviderConf reloads the configuration from the provider
// and apply it if different from the active one
func ReloadProviderConf() {
	loadConfigFromProvider() //nolint:errcheck
}

func loadConfigFromProvider() error {
	configs, err := dataprovider.GetConfigs()
	if err != nil {
		logger.Error(logSender, "", "unable to load config from provider: %v", err)
		return fmt.Errorf("smtp: unable to load config from provider: %w", err)
	}
	configs.SetNilsToEmpty()
	if err := configs.SMTP.TryDecrypt(); err != nil {
		logger.Error(logSender, "", "unable to decrypt smtp config: %v", err)
		return fmt.Errorf("smtp: unable to decrypt smtp config: %w", err)
	}
	config.Set(configs.SMTP)
	return nil
}

func updateRefreshToken(token string) {
	configs, err := dataprovider.GetConfigs()
	if err != nil {
		logger.Error(logSender, "", "unable to load config from provider, updating refresh token not possible: %v", err)
		return
	}
	configs.SetNilsToEmpty()
	if configs.SMTP.IsEmpty() {
		logger.Warn(logSender, "", "unable to update refresh token, smtp not configured in the data provider")
		return
	}
	configs.SMTP.OAuth2.RefreshToken = kms.NewPlainSecret(token)
	if err := dataprovider.UpdateConfigs(&configs, dataprovider.ActionExecutorSystem, "", ""); err != nil {
		logger.Error(logSender, "", "unable to save new refresh token: %v", err)
		return
	}
	logger.Info(logSender, "", "refresh token updated")
}
