package smtp

import (
	"context"
	"crypto/tls"
	"fmt"
	"github.com/pkg/errors"
	"net"
	"net/smtp"
	"net/url"
	"strconv"
	"time"

	"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
	"github.com/ooni/probe-cli/v3/internal/measurexlite"
	"github.com/ooni/probe-cli/v3/internal/model"
	"github.com/ooni/probe-cli/v3/internal/tracex"
)

var (
	// errNoInputProvided indicates you didn't provide any input
	errNoInputProvided = errors.New("not input provided")

	// errInputIsNotAnURL indicates that input is not an URL
	errInputIsNotAnURL = errors.New("input is not an URL")

	// errInvalidScheme indicates that the scheme is invalid
	errInvalidScheme = errors.New("scheme must be smtp(s)")

	// errInvalidPort indicates that the port provided could not be parsed as an int
	errInvalidPort = errors.New("Port number is not a valid integer")
)

const (
	testName    = "smtp"
	testVersion = "0.0.1"
)

// Config contains the experiment config.
type Config struct {
	host       string
	port       string
	forced_tls bool
	noop_count uint8
}

func config(input model.MeasurementTarget) (*Config, error) {
	if input == "" {
		// TODO: static input data (eg. gmail/riseup..)
		return nil, errNoInputProvided
	}

	parsed, err := url.Parse(string(input))
	if err != nil {
		return nil, fmt.Errorf("%w: %s", errInputIsNotAnURL, err.Error())
	}
	if parsed.Scheme != "smtp" && parsed.Scheme != "smtps" {
		return nil, errInvalidScheme
	}

	port := ""

	if parsed.Port() == "" {
		// Default ports for StartTLS and forced TLS respectively
		if parsed.Scheme == "smtp" {
			port = "587"
		} else {
			port = "465"
		}
	} else {
		// Check that requested port is a valid integer
		_, err := strconv.Atoi(parsed.Port())
		if err != nil {
			return nil, errInvalidPort
		} else {
			port = parsed.Port()
		}
	}

	valid_config := Config{
		host:       parsed.Hostname(),
		forced_tls: parsed.Scheme == "smtps",
		port:       port,
		noop_count: 10,
	}

	return &valid_config, nil
}

// TestKeys contains the experiment results
type TestKeys struct {
	Queries       []*model.ArchivalDNSLookupResult          `json:"queries"`
	TCPConnect    []*model.ArchivalTCPConnectResult         `json:"tcp_connect"`
	TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"`
	SMTPErrors    map[string][]*string                      `json:"smtp"`
	NoOpCounter   uint8                                     `json:"successful_noops"`
	// Used for global failure (DNS resolution)
	Failure string `json:"failure"`
}

type Measurer struct {
	// Config contains the experiment settings. If empty we
	// will be using default settings.
	Config Config

	// Getter is an optional getter to be used for testing.
	Getter urlgetter.MultiGetter
}

// ExperimentName implements ExperimentMeasurer.ExperimentName
func (m Measurer) ExperimentName() string {
	return testName
}

// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion
func (m Measurer) ExperimentVersion() string {
	return testVersion
}

// Manages sequential SMTP sessions to the same hostname (over different IPs)
// don't use in parallel!
type SMTPRunner struct {
	trace     *measurexlite.Trace
	logger    model.Logger
	ctx       context.Context
	tk        *TestKeys
	tlsconfig *tls.Config
	host      string
	port      string
	// addr is changed everytime SMTPRunner.conn(addr) is called
	addr string
}

func (r SMTPRunner) smtp_error(err error) {
	key := net.JoinHostPort(r.addr, r.port)
	errors, exists := r.tk.SMTPErrors[key]
	if exists {
		r.tk.SMTPErrors[key] = append(errors, tracex.NewFailure(err))
	} else {
		r.tk.SMTPErrors[key] = []*string{tracex.NewFailure(err)}
	}
}

func (r SMTPRunner) resolve(host string) ([]string, bool) {
	r.logger.Infof("Resolving DNS for %s", host)
	resolver := r.trace.NewStdlibResolver(r.logger)
	addrs, err := resolver.LookupHost(r.ctx, host)
	r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...)
	if err != nil {
		r.tk.Failure = *tracex.NewFailure(err)
		return []string{}, false
	}
	r.logger.Infof("Finished DNS for %s: %v", host, addrs)

	return addrs, true
}

func (r SMTPRunner) conn(addr string) (net.Conn, bool) {
	r.addr = addr
	dialer := r.trace.NewDialerWithoutResolver(r.logger)
	conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(r.addr, r.port))
	r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...)
	if err != nil {
		r.smtp_error(err)
		return nil, false
	}
	return conn, true
}

func (r SMTPRunner) handshake(conn net.Conn) (net.Conn, bool) {
	r.logger.Infof("Starting TLS handshake with %s:%s (%s)", r.host, r.port, r.addr)
	thx := r.trace.NewTLSHandshakerStdlib(r.logger)
	tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig)
	r.tk.TLSHandshakes = append(r.tk.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil())
	if err != nil {
		r.smtp_error(err)
		return nil, false
	}
	r.logger.Infof("Handshake succeeded")
	return tconn, true
}

func (r SMTPRunner) starttls(conn net.Conn, message string) (net.Conn, bool) {
	if message != "" {
		r.logger.Infof("Asking for StartTLS upgrade")
		conn.Write([]byte(message))
	}
	tconn, success := r.handshake(conn)
	return tconn, success
}

func (r SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool {
	client, err := smtp.NewClient(conn, ehlo)
	if err != nil {
		r.smtp_error(err)
		return false
	}
	err = client.Hello(ehlo)
	if err != nil {
		r.smtp_error(err)
		return false
	}

	if noop > 0 {
		r.logger.Infof("Trying to generate more no-op traffic")
		// TODO: noop counter per IP address
		r.tk.NoOpCounter = 0
		for r.tk.NoOpCounter < noop {
			r.tk.NoOpCounter += 1
			r.logger.Infof("NoOp Iteration %d", r.tk.NoOpCounter)
			err = client.Noop()
			if err != nil {
				r.smtp_error(err)
				break
			}
		}

		if r.tk.NoOpCounter == noop {
			r.logger.Infof("Successfully generated no-op traffic")
			return true
		} else {
			r.logger.Infof("Failed no-op traffic at iteration %d", r.tk.NoOpCounter)
			return false
		}
	}

	return true
}

// Run implements ExperimentMeasurer.Run
func (m Measurer) Run(
	ctx context.Context, sess model.ExperimentSession,
	measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {

	log := sess.Logger()
	trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved)

	config, err := config(measurement.Input)
	if err != nil {
		// Invalid input data, we don't even generate report
		return err
	}

	tk := new(TestKeys)
	// TODO: make it so we don't forget it
	tk.SMTPErrors = make(map[string][]*string)
	measurement.TestKeys = tk

	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()

	tlsconfig := tls.Config{
		InsecureSkipVerify: false,
		ServerName:         config.host,
	}

	runner := SMTPRunner{
		trace:     trace,
		logger:    log,
		ctx:       ctx,
		tk:        tk,
		tlsconfig: &tlsconfig,
		host:      config.host,
		port:      config.port,
		addr:      "",
	}

	// First resolve DNS
	addrs, success := runner.resolve(config.host)
	if !success {
		return nil
	}

	for _, addr := range addrs {
		conn, success := runner.conn(addr)
		if !success {
			return nil
		}
		defer conn.Close()

		if config.forced_tls {
			// Direct TLS connection
			tconn, success := runner.handshake(conn)
			if !success {
				continue
			}
			defer tconn.Close()

			// Try EHLO + NoOps
			if !runner.smtp(tconn, "localhost", 10) {
				continue
			}
		} else {
			// StartTLS... first try plaintext EHLO
			if !runner.smtp(conn, "localhost", 0) {
				continue
			}

			// Upgrade via StartTLS and try EHLO + NoOps
			tconn, success := runner.starttls(conn, "STARTTLS\n")
			if !success {
				continue
			}
			defer tconn.Close()

			if !runner.smtp(tconn, "localhost", 10) {
				continue
			}
		}
	}

	return nil
}

// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
	return Measurer{Config: config}
}

// SummaryKeys contains summary keys for this experiment.
//
// Note that this structure is part of the ABI contract with ooniprobe
// therefore we should be careful when changing it.
type SummaryKeys struct {
	//DNSBlocking bool `json:"facebook_dns_blocking"`
	//TCPBlocking bool `json:"facebook_tcp_blocking"`
	IsAnomaly bool `json:"-"`
}

// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
	sk := SummaryKeys{IsAnomaly: false}
	_, ok := measurement.TestKeys.(*TestKeys)
	if !ok {
		return sk, errors.New("invalid test keys type")
	}
	return sk, nil
}
