package main

import (
	"bytes"
	"encoding/csv"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"net/mail"
	"os"
	"sort"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/jmhodges/clock"
	"github.com/letsencrypt/boulder/cmd"
	"github.com/letsencrypt/boulder/db"
	"github.com/letsencrypt/boulder/features"
	blog "github.com/letsencrypt/boulder/log"
	bmail "github.com/letsencrypt/boulder/mail"
	"github.com/letsencrypt/boulder/metrics"
	"github.com/letsencrypt/boulder/policy"
	"github.com/letsencrypt/boulder/sa"
)

type mailer struct {
	clk           clock.Clock
	log           blog.Logger
	dbMap         dbSelector
	mailer        bmail.Mailer
	subject       string
	emailTemplate *template.Template
	destinations  []recipient
	targetRange   interval
	sleepInterval time.Duration
}

// interval defines a range of email addresses to send to, alphabetically.
// The "start" field is inclusive and the "end" field is exclusive.
// To include everything, set "end" to "\xFF".
type interval struct {
	start string
	end   string
}

type contactJSON struct {
	ID      int
	Contact []byte
}

func (i *interval) ok() error {
	if i.start > i.end {
		return fmt.Errorf(
			"interval start value (%s) is greater than end value (%s)",
			i.start, i.end)
	}

	return nil
}

func (i *interval) includes(s string) bool {
	return s >= i.start && s < i.end
}

func (m *mailer) ok() error {
	// Make sure the checkpoint range is OK
	if checkpointErr := m.targetRange.ok(); checkpointErr != nil {
		return checkpointErr
	}

	// Do not allow a negative sleep interval
	if m.sleepInterval < 0 {
		return fmt.Errorf(
			"sleep interval (%d) is < 0", m.sleepInterval)
	}

	return nil
}

func (m *mailer) printStatus(to string, cur, total int, start time.Time) {
	// Should never happen
	if total <= 0 || cur < 1 || cur > total {
		m.log.AuditErrf("invalid cur (%d) or total (%d)", cur, total)
	}
	completion := (float32(cur) / float32(total)) * 100
	now := m.clk.Now()
	elapsed := now.Sub(start)
	m.log.Infof("Sending to %q. Message %d of %d [%.2f%%]. Elapsed: %s",
		to, cur, total, completion, elapsed)
}

func sortAddresses(input emailToRecipientMap) []string {
	var addresses []string
	for k := range input {
		addresses = append(addresses, k)
	}
	sort.Strings(addresses)
	return addresses
}

func (m *mailer) run() error {
	if err := m.ok(); err != nil {
		return err
	}

	m.log.Infof("Resolving %d destination addresses", len(m.destinations))
	addressesToRecipients, err := m.resolveEmailAddresses()
	if err != nil {
		return err
	}
	if len(addressesToRecipients) == 0 {
		return fmt.Errorf("zero recipients after looking up addresses?")
	}
	m.log.Infof("Resolved destination addresses. %d accounts became %d addresses.",
		len(m.destinations), len(addressesToRecipients))
	var biggest int
	var biggestAddress string
	for k, v := range addressesToRecipients {
		if len(v) > biggest {
			biggest = len(v)
			biggestAddress = k
		}
	}
	m.log.Infof("Most frequent address %q had %d associated lines", biggestAddress, biggest)

	err = m.mailer.Connect()
	if err != nil {
		return err
	}
	defer func() {
		_ = m.mailer.Close()
	}()

	startTime := m.clk.Now()

	sortedAddresses := sortAddresses(addressesToRecipients)
	numAddresses := len(addressesToRecipients)

	var sent int
	for i, address := range sortedAddresses {
		if !m.targetRange.includes(address) {
			m.log.Debugf("skipping %q: out of target range")
			continue
		}
		if err := policy.ValidEmail(address); err != nil {
			m.log.Infof("skipping %q: %s", address, err)
			continue
		}
		recipients := addressesToRecipients[address]
		m.printStatus(address, i+1, numAddresses, startTime)
		var mailBody bytes.Buffer
		err = m.emailTemplate.Execute(&mailBody, recipients)
		if err != nil {
			return err
		}
		if mailBody.Len() == 0 {
			return fmt.Errorf("email body was empty after interpolation.")
		}
		err := m.mailer.SendMail([]string{address}, m.subject, mailBody.String())
		if err != nil {
			var recoverableSMTPErr bmail.RecoverableSMTPError
			if errors.As(err, &recoverableSMTPErr) {
				m.log.Errf("address %q was rejected by server: %s", address, err)
				continue
			}
			return fmt.Errorf("sending mail %d of %d to %q: %s",
				i, len(sortedAddresses), address, err)
		}
		sent++
		m.clk.Sleep(m.sleepInterval)
	}
	if sent == 0 {
		return fmt.Errorf("sent zero messages. Check recipients and configured interval")
	}
	return nil
}

// resolveEmailAddresses looks up the id of each recipient to find that
// account's email addresses, then adds that recipient to a map from address to
// recipient struct.
func (m *mailer) resolveEmailAddresses() (emailToRecipientMap, error) {
	result := make(emailToRecipientMap, len(m.destinations))

	for _, r := range m.destinations {
		// Get the email address for the reg ID
		emails, err := emailsForReg(r.id, m.dbMap)
		if err != nil {
			return nil, err
		}

		for _, email := range emails {
			parsedEmail, err := mail.ParseAddress(email)
			if err != nil {
				m.log.Errf("unparsable email for reg ID %d : %q", r.id, email)
				continue
			}
			addr := parsedEmail.Address
			result[addr] = append(result[addr], r)
		}
	}
	return result, nil
}

// Since the only thing we use from gorp is the SelectOne method on the
// gorp.DbMap object, we just define an interface with that method
// instead of importing all of gorp. This facilitates mock implementations for
// unit tests
type dbSelector interface {
	SelectOne(holder interface{}, query string, args ...interface{}) error
}

// Finds the email addresses associated with a reg ID
func emailsForReg(id int, dbMap dbSelector) ([]string, error) {
	var contact contactJSON
	err := dbMap.SelectOne(&contact,
		`SELECT id, contact
		FROM registrations
		WHERE contact != 'null' AND id = :id;`,
		map[string]interface{}{
			"id": id,
		})
	if err != nil {
		if db.IsNoRows(err) {
			return []string{}, nil
		}
		return nil, err
	}

	var contactFields []string
	var addresses []string
	err = json.Unmarshal(contact.Contact, &contactFields)
	if err != nil {
		return nil, err
	}
	for _, entry := range contactFields {
		if strings.HasPrefix(entry, "mailto:") {
			addresses = append(addresses, strings.TrimPrefix(entry, "mailto:"))
		}
	}
	return addresses, nil
}

// recipient represents one line in the input CSV, containing an account and
// (optionally) some extra fields related to that account.
type recipient struct {
	id    int
	Extra map[string]string
}

// emailToRecipientMap maps from an email address to a list of recipients with
// that email address.
type emailToRecipientMap map[string][]recipient

// readRecipientsList reads a CSV filename and parses that file into a list of
// recipient structs. It puts any columns after the first into a per-recipient
// map from column name -> value.
func readRecipientsList(filename string) ([]recipient, error) {
	f, err := os.Open(filename)
	if err != nil {
		return nil, err
	}
	reader := csv.NewReader(f)
	record, err := reader.Read()
	if err != nil {
		return nil, err
	}
	if len(record) == 0 {
		return nil, fmt.Errorf("no entries in CSV")
	}
	if record[0] != "id" {
		return nil, fmt.Errorf("first field of CSV input must be an ID.")
	}
	var columnNames []string
	for _, v := range record[1:] {
		columnNames = append(columnNames, strings.TrimSpace(v))
	}

	results := []recipient{}
	for {
		record, err := reader.Read()
		if err == io.EOF {
			if len(results) == 0 {
				return nil, fmt.Errorf("no entries after the header in CSV")
			}
			return results, nil
		}
		if err != nil {
			return nil, err
		}
		if len(record) == 0 {
			return nil, fmt.Errorf("empty line in CSV")
		}
		if len(record) != len(columnNames)+1 {
			return nil, fmt.Errorf("Number of columns in CSV line didn't match header columns."+
				" Got %d, expected %d. Line: %v", len(record), len(columnNames)+1, record)
		}
		id, err := strconv.Atoi(record[0])
		if err != nil {
			return nil, err
		}
		recip := recipient{
			id:    id,
			Extra: make(map[string]string),
		}
		for i, v := range record[1:] {
			recip.Extra[columnNames[i]] = v
		}
		results = append(results, recip)
	}
}

const usageIntro = `
Introduction:

The notification mailer exists to send a message to the contact associated
with a list of registration IDs. The attributes of the message (from address,
subject, and message content) are provided by the command line arguments. The
message content is provided as a path to a template file via the -body argument.

Provide a list of recipient user ids in a CSV file passed with the -recipientList
flag. The CSV file must have "id" as the first column and may have additional
fields to be interpolated into the email template:

	id, lastIssuance
	1234, "from example.com 2018-12-01"
	5678, "from example.net 2018-12-13"

The additional fields will be interpolated with Golang templating, e.g.:

  Your last issuance on each account was:
		{{ range . }} {{ .Extra.lastIssuance }}
		{{ end }}

To help the operator gain confidence in the mailing run before committing fully
three safety features are supported: dry runs, intervals and a sleep between emails.

The -dryRun=true flag will use a mock mailer that prints message content to
stdout instead of performing an SMTP transaction with a real mailserver. This
can be used when the initial parameters are being tweaked to ensure no real
emails are sent. Using -dryRun=false will send real email.

Intervals supported via the -start and -end arguments. Only email addresses that
are alphabetically between the -start and -end strings will be sent. This can be used
to break up sending into batches, or more likely to resume sending if a batch is killed,
without resending messages that have already been sent. The -start flag is inclusive and
the -end flag is exclusive.

Notify-mailer de-duplicates email addresses and groups together the resulting recipient
structs, so a person who has multiple accounts using the same address will only receive
one email.

During mailing the -sleep argument is used to space out individual messages.
This can be used to ensure that the mailing happens at a steady pace with ample
opportunity for the operator to terminate early in the event of error. The
-sleep flag honours durations with a unit suffix (e.g. 1m for 1 minute, 10s for
10 seconds, etc). Using -sleep=0 will disable the sleep and send at full speed.

Examples:
  Send an email with subject "Hello!" from the email "hello@goodbye.com" with
  the contents read from "test_msg_body.txt" to every email associated with the
  registration IDs listed in "test_reg_recipients.json", sleeping 10 seconds
  between each message:

  notify-mailer -config test/config/notify-mailer.json -body
    cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com
    -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!"
    -sleep 10s -dryRun=false

  Do the same, but only to example@example.com:

  notify-mailer -config test/config/notify-mailer.json
    -body cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com
    -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!"
    -start example@example.com -end example@example.comX

  Send the message starting with example@example.com and emailing every address that's
	alphabetically higher:

  notify-mailer -config test/config/notify-mailer.json 
    -body cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com 
    -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!"
    -start example@example.com

Required arguments:
- body
- config
- from
- subject
- recipientList`

func main() {
	from := flag.String("from", "", "From header for emails. Must be a bare email address.")
	subject := flag.String("subject", "", "Subject of emails")
	recipientListFile := flag.String("recipientList", "", "File containing a CSV list of registration IDs and extra info.")
	bodyFile := flag.String("body", "", "File containing the email body in Golang template format.")
	dryRun := flag.Bool("dryRun", true, "Whether to do a dry run.")
	sleep := flag.Duration("sleep", 500*time.Millisecond, "How long to sleep between emails.")
	start := flag.String("start", "", "Alphabetically lowest email address to include.")
	end := flag.String("end", "\xFF", "Alphabetically highest email address (exclusive).")
	reconnBase := flag.Duration("reconnectBase", 1*time.Second, "Base sleep duration between reconnect attempts")
	reconnMax := flag.Duration("reconnectMax", 5*60*time.Second, "Max sleep duration between reconnect attempts after exponential backoff")
	type config struct {
		NotifyMailer struct {
			DB cmd.DBConfig
			// TODO(#5275): Remove once all configs in dev, staging and prod
			// have been updated to contain the `dbconfig` field
			cmd.DeprecatedDBConfig
			cmd.PasswordConfig
			cmd.SMTPConfig
			Features map[string]bool
		}
		Syslog cmd.SyslogConfig
	}
	configFile := flag.String("config", "", "File containing a JSON config.")

	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "%s\n\n", usageIntro)
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
		flag.PrintDefaults()
	}

	flag.Parse()
	if *from == "" || *subject == "" || *bodyFile == "" || *configFile == "" ||
		*recipientListFile == "" {
		flag.Usage()
		os.Exit(1)
	}

	configData, err := ioutil.ReadFile(*configFile)
	cmd.FailOnError(err, fmt.Sprintf("Reading %q", *configFile))
	var cfg config
	err = json.Unmarshal(configData, &cfg)
	cmd.FailOnError(err, "Unmarshaling config")
	err = features.Set(cfg.NotifyMailer.Features)
	cmd.FailOnError(err, "Failed to set feature flags")

	log := cmd.NewLogger(cfg.Syslog)
	defer log.AuditPanic()

	// TODO(#5275): Remove once all configs in dev, staging and prod
	// have been updated to contain the `dbconfig` field
	cmd.DefaultDBConfig(&cfg.NotifyMailer.DB, &cfg.NotifyMailer.DeprecatedDBConfig)
	dbURL, err := cfg.NotifyMailer.DB.URL()
	cmd.FailOnError(err, "Couldn't load DB URL")
	dbSettings := sa.DbSettings{
		MaxOpenConns: 10,
	}
	dbMap, err := sa.NewDbMap(dbURL, dbSettings)
	cmd.FailOnError(err, "Could not connect to database")

	// Load email body
	body, err := ioutil.ReadFile(*bodyFile)
	cmd.FailOnError(err, fmt.Sprintf("Reading %q", *bodyFile))
	template, err := template.New("email").Parse(string(body))
	cmd.FailOnError(err, fmt.Sprintf("Parsing template %q", *bodyFile))

	address, err := mail.ParseAddress(*from)
	cmd.FailOnError(err, fmt.Sprintf("Parsing %q", *from))

	recipients, err := readRecipientsList(*recipientListFile)
	cmd.FailOnError(err, fmt.Sprintf("Reading %q", *recipientListFile))

	targetRange := interval{
		start: *start,
		end:   *end,
	}

	var mailClient bmail.Mailer
	if *dryRun {
		log.Infof("Doing a dry run.")
		mailClient = bmail.NewDryRun(*address, log)
	} else {
		smtpPassword, err := cfg.NotifyMailer.PasswordConfig.Pass()
		cmd.FailOnError(err, "Failed to load SMTP password")
		mailClient = bmail.New(
			cfg.NotifyMailer.Server,
			cfg.NotifyMailer.Port,
			cfg.NotifyMailer.Username,
			smtpPassword,
			nil,
			*address,
			log,
			metrics.NoopRegisterer,
			*reconnBase,
			*reconnMax)
	}

	m := mailer{
		clk:           cmd.Clock(),
		log:           log,
		dbMap:         dbMap,
		mailer:        mailClient,
		subject:       *subject,
		destinations:  recipients,
		emailTemplate: template,
		targetRange:   targetRange,
		sleepInterval: *sleep,
	}

	err = m.run()
	cmd.FailOnError(err, "mailer.send returned error")
}
