package probers

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"golang.org/x/crypto/ocsp"

	"github.com/letsencrypt/boulder/observer/obsclient"
)

type reason int

const (
	none reason = iota
	internalError
	revocationStatusError
	rootDidNotMatch
	statusDidNotMatch
)

var reasonToString = map[reason]string{
	none:                  "nil",
	internalError:         "internalError",
	revocationStatusError: "revocationStatusError",
	rootDidNotMatch:       "rootDidNotMatch",
	statusDidNotMatch:     "statusDidNotMatch",
}

func getReasons() []string {
	var allReasons []string
	for _, v := range reasonToString {
		allReasons = append(allReasons, v)
	}
	return allReasons
}

// TLSProbe is the exported `Prober` object for monitors configured to perform
// TLS protocols.
type TLSProbe struct {
	hostname  string
	rootOrg   string
	rootCN    string
	response  string
	notAfter  *prometheus.GaugeVec
	notBefore *prometheus.GaugeVec
	reason    *prometheus.CounterVec
}

// Name returns a string that uniquely identifies the monitor.
func (p TLSProbe) Name() string {
	return p.hostname
}

// Kind returns a name that uniquely identifies the `Kind` of `Prober`.
func (p TLSProbe) Kind() string {
	return "TLS"
}

// Get OCSP status (good, revoked or unknown) of certificate
func checkOCSP(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
	req, err := ocsp.CreateRequest(cert, issuer, nil)
	if err != nil {
		return false, err
	}

	url := fmt.Sprintf("%s/%s", cert.OCSPServer[0], base64.StdEncoding.EncodeToString(req))
	r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return false, err
	}

	res, err := http.DefaultClient.Do(r)
	if err != nil {
		return false, err
	}

	output, err := io.ReadAll(res.Body)
	if err != nil {
		return false, err
	}

	ocspRes, err := ocsp.ParseResponseForCert(output, cert, issuer)
	if err != nil {
		return false, err
	}

	return ocspRes.Status == want, nil
}

func checkCRL(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
	if len(cert.CRLDistributionPoints) != 1 {
		return false, errors.New("cert does not contain CRLDP URI")
	}

	req, err := http.NewRequestWithContext(ctx, "GET", cert.CRLDistributionPoints[0], nil)
	if err != nil {
		return false, fmt.Errorf("creating HTTP request: %w", err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return false, fmt.Errorf("downloading CRL: %w", err)
	}
	defer resp.Body.Close()

	der, err := io.ReadAll(resp.Body)
	if err != nil {
		return false, fmt.Errorf("reading CRL: %w", err)
	}

	crl, err := x509.ParseRevocationList(der)
	if err != nil {
		return false, fmt.Errorf("parsing CRL: %w", err)
	}

	err = crl.CheckSignatureFrom(issuer)
	if err != nil {
		return false, fmt.Errorf("validating CRL: %w", err)
	}

	for _, entry := range crl.RevokedCertificateEntries {
		if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 {
			return want == ocsp.Revoked, nil
		}
	}
	return want == ocsp.Good, nil
}

// Return an error if the root settings are nonempty and do not match the
// expected root.
func (p TLSProbe) checkRoot(rootOrg, rootCN string) error {
	if (p.rootCN == "" && p.rootOrg == "") || (rootOrg == p.rootOrg && rootCN == p.rootCN) {
		return nil
	}
	return fmt.Errorf("Expected root does not match.")
}

// Export expiration timestamp and reason to Prometheus.
func (p TLSProbe) exportMetrics(cert *x509.Certificate, reason reason) {
	if cert != nil {
		p.notAfter.WithLabelValues(p.hostname).Set(float64(cert.NotAfter.Unix()))
		p.notBefore.WithLabelValues(p.hostname).Set(float64(cert.NotBefore.Unix()))
	}
	p.reason.WithLabelValues(p.hostname, reasonToString[reason]).Inc()
}

func (p TLSProbe) probeExpired(ctx context.Context) error {
	addr := p.hostname
	_, _, err := net.SplitHostPort(addr)
	if err != nil {
		addr = net.JoinHostPort(addr, "443")
	}

	tlsDialer := tls.Dialer{
		NetDialer: obsclient.Dialer(),
		Config: &tls.Config{
			// Set InsecureSkipVerify to skip the default validation we are
			// replacing. This will not disable VerifyConnection.
			InsecureSkipVerify: true,
			VerifyConnection: func(cs tls.ConnectionState) error {
				issuers := x509.NewCertPool()
				for _, cert := range cs.PeerCertificates[1:] {
					issuers.AddCert(cert)
				}
				opts := x509.VerifyOptions{
					// We set the current time to be the cert's expiration date so that
					// the validation routine doesn't complain that the cert is expired.
					CurrentTime: cs.PeerCertificates[0].NotAfter,
					// By settings roots and intermediates to be whatever was presented
					// in the handshake, we're saying that we don't care about the cert
					// chaining up to the system trust store. This is safe because we
					// check the root ourselves in checkRoot().
					Intermediates: issuers,
					Roots:         issuers,
				}
				_, err := cs.PeerCertificates[0].Verify(opts)
				return err
			},
		},
	}

	conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
	if err != nil {
		p.exportMetrics(nil, internalError)
		return err
	}
	defer conn.Close()

	// tls.Dialer.DialContext is documented to always return *tls.Conn
	peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
	if time.Until(peers[0].NotAfter) > 0 {
		p.exportMetrics(peers[0], statusDidNotMatch)
		return fmt.Errorf("certificate is not expired, notAfter %s", peers[0].NotAfter)
	}

	root := peers[len(peers)-1].Issuer
	err = p.checkRoot(root.Organization[0], root.CommonName)
	if err != nil {
		p.exportMetrics(peers[0], rootDidNotMatch)
		return err
	}

	p.exportMetrics(peers[0], none)
	return nil
}

func (p TLSProbe) probeUnexpired(ctx context.Context) error {
	addr := p.hostname
	_, _, err := net.SplitHostPort(addr)
	if err != nil {
		addr = net.JoinHostPort(addr, "443")
	}

	tlsDialer := tls.Dialer{
		NetDialer: obsclient.Dialer(),
		Config: &tls.Config{
			// Set InsecureSkipVerify to skip the default validation we are
			// replacing. This will not disable VerifyConnection.
			InsecureSkipVerify: true,
			VerifyConnection: func(cs tls.ConnectionState) error {
				issuers := x509.NewCertPool()
				for _, cert := range cs.PeerCertificates[1:] {
					issuers.AddCert(cert)
				}
				opts := x509.VerifyOptions{
					// By settings roots and intermediates to be whatever was presented
					// in the handshake, we're saying that we don't care about the cert
					// chaining up to the system trust store. This is safe because we
					// check the root ourselves in checkRoot().
					Intermediates: issuers,
					Roots:         issuers,
				}
				_, err := cs.PeerCertificates[0].Verify(opts)
				return err
			},
		},
	}

	conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
	if err != nil {
		p.exportMetrics(nil, internalError)
		return err
	}
	defer conn.Close()

	// tls.Dialer.DialContext is documented to always return *tls.Conn
	peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
	root := peers[len(peers)-1].Issuer
	err = p.checkRoot(root.Organization[0], root.CommonName)
	if err != nil {
		p.exportMetrics(peers[0], rootDidNotMatch)
		return err
	}

	var wantStatus int
	switch p.response {
	case "valid":
		wantStatus = ocsp.Good
	case "revoked":
		wantStatus = ocsp.Revoked
	}

	var statusMatch bool
	if len(peers[0].OCSPServer) != 0 {
		statusMatch, err = checkOCSP(ctx, peers[0], peers[1], wantStatus)
	} else {
		statusMatch, err = checkCRL(ctx, peers[0], peers[1], wantStatus)
	}
	if err != nil {
		p.exportMetrics(peers[0], revocationStatusError)
		return err
	}

	if !statusMatch {
		p.exportMetrics(peers[0], statusDidNotMatch)
		return fmt.Errorf("unexpected certificate revocation status, want %v", wantStatus)
	}

	p.exportMetrics(peers[0], none)
	return nil
}

// Probe performs the configured TLS probe. Returns nil if the root has the
// expected Subject (or if no root is provided for comparison in settings), and
// the end entity certificate has the correct expiration status (either expired
// or unexpired, depending on what is configured). Exports metrics for the
// validity interval of the end entity certificate and the result of the probe.
func (p TLSProbe) Probe(ctx context.Context) error {
	if p.response == "expired" {
		return p.probeExpired(ctx)
	} else {
		return p.probeUnexpired(ctx)
	}
}
