package integration

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.step.sm/crypto/jose"
	"go.step.sm/crypto/keyutil"
	"go.step.sm/crypto/minica"
	"go.step.sm/crypto/pemutil"
	"go.step.sm/crypto/randutil"
	"go.step.sm/crypto/x509util"

	"github.com/smallstep/certificates/api"
	"github.com/smallstep/certificates/authority/config"
	"github.com/smallstep/certificates/authority/provisioner"
	"github.com/smallstep/certificates/ca"
	"github.com/smallstep/certificates/ca/client"
	"github.com/smallstep/certificates/errs"
)

// reservePort "reserves" a TCP port by opening a listener on a random
// port and immediately closing it. The port can then be assumed to be
// available for running a server on.
func reservePort(t *testing.T) (host, port string) {
	t.Helper()
	l, err := net.Listen("tcp", ":0")
	require.NoError(t, err)

	address := l.Addr().String()
	err = l.Close()
	require.NoError(t, err)

	host, port, err = net.SplitHostPort(address)
	require.NoError(t, err)

	return
}

func Test_reflectRequestID(t *testing.T) {
	dir := t.TempDir()
	m, err := minica.New(minica.WithName("Step E2E"))
	require.NoError(t, err)

	rootFilepath := filepath.Join(dir, "root.crt")
	_, err = pemutil.Serialize(m.Root, pemutil.WithFilename(rootFilepath))
	require.NoError(t, err)

	intermediateCertFilepath := filepath.Join(dir, "intermediate.crt")
	_, err = pemutil.Serialize(m.Intermediate, pemutil.WithFilename(intermediateCertFilepath))
	require.NoError(t, err)

	intermediateKeyFilepath := filepath.Join(dir, "intermediate.key")
	_, err = pemutil.Serialize(m.Signer, pemutil.WithFilename(intermediateKeyFilepath))
	require.NoError(t, err)

	// get a random address to listen on and connect to; currently no nicer way to get one before starting the server
	// TODO(hs): find/implement a nicer way to expose the CA URL, similar to how e.g. httptest.Server exposes it?
	host, port := reservePort(t)

	authorizingSrv := newAuthorizingServer(t, m)
	defer authorizingSrv.Close()
	authorizingSrv.StartTLS()

	password := []byte("1234")
	jwk, jwe, err := jose.GenerateDefaultKeyPair(password)
	require.NoError(t, err)
	encryptedKey, err := jwe.CompactSerialize()
	require.NoError(t, err)
	prov := &provisioner.JWK{
		ID:           "jwk",
		Name:         "jwk",
		Type:         "JWK",
		Key:          jwk,
		EncryptedKey: encryptedKey,
		Claims:       &config.GlobalProvisionerClaims,
		Options: &provisioner.Options{
			Webhooks: []*provisioner.Webhook{
				{
					ID:       "webhook",
					Name:     "webhook-test",
					URL:      fmt.Sprintf("%s/authorize", authorizingSrv.URL),
					Kind:     "AUTHORIZING",
					CertType: "X509",
				},
			},
		},
	}
	err = prov.Init(provisioner.Config{})
	require.NoError(t, err)

	cfg := &config.Config{
		Root:             []string{rootFilepath},
		IntermediateCert: intermediateCertFilepath,
		IntermediateKey:  intermediateKeyFilepath,
		Address:          net.JoinHostPort(host, port), // reuse the address that was just "reserved"
		DNSNames:         []string{"127.0.0.1", "[::1]", "localhost"},
		AuthorityConfig: &config.AuthConfig{
			AuthorityID:    "stepca-test",
			DeploymentType: "standalone-test",
			Provisioners:   provisioner.List{prov},
		},
		Logger: json.RawMessage(`{"format": "text"}`),
	}
	c, err := ca.New(cfg)
	require.NoError(t, err)

	// instantiate a client for the CA running at the random address
	caClient, err := ca.NewClient(
		fmt.Sprintf("https://localhost:%s", port),
		ca.WithRootFile(rootFilepath),
	)
	require.NoError(t, err)

	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		err = c.Run()
		require.ErrorIs(t, err, http.ErrServerClosed)
	}()

	// require OK health response as the baseline
	ctx := context.Background()
	healthResponse, err := caClient.HealthWithContext(ctx)
	require.NoError(t, err)
	if assert.NotNil(t, healthResponse) {
		require.Equal(t, "ok", healthResponse.Status)
	}

	// expect an error when retrieving an invalid root
	rootResponse, err := caClient.RootWithContext(ctx, "invalid")
	var firstErr *errs.Error
	if assert.ErrorAs(t, err, &firstErr) {
		assert.Equal(t, 404, firstErr.StatusCode())
		assert.Equal(t, "The requested resource could not be found. Please see the certificate authority logs for more info.", firstErr.Err.Error())
		assert.NotEmpty(t, firstErr.RequestID)

		// TODO: include the below error in the JSON? It's currently only output to the CA logs. Also see https://github.com/smallstep/certificates/pull/759
		//assert.Equal(t, "/root/invalid was not found: certificate with fingerprint invalid was not found", apiErr.Msg)
	}
	assert.Nil(t, rootResponse)

	// expect an error when retrieving an invalid root and provided request ID
	rootResponse, err = caClient.RootWithContext(client.NewRequestIDContext(ctx, "reqID"), "invalid")
	var secondErr *errs.Error
	if assert.ErrorAs(t, err, &secondErr) {
		assert.Equal(t, 404, secondErr.StatusCode())
		assert.Equal(t, "The requested resource could not be found. Please see the certificate authority logs for more info.", secondErr.Err.Error())
		assert.Equal(t, "reqID", secondErr.RequestID)
	}
	assert.Nil(t, rootResponse)

	// prepare a Sign request
	subject := "test"
	decryptedJWK := decryptPrivateKey(t, jwe, password)
	ott := generateOTT(t, decryptedJWK, subject)

	signer, err := keyutil.GenerateDefaultSigner()
	require.NoError(t, err)

	csr, err := x509util.CreateCertificateRequest(subject, []string{subject}, signer)
	require.NoError(t, err)

	// perform the Sign request using the OTT and CSR
	signResponse, err := caClient.SignWithContext(client.NewRequestIDContext(ctx, "signRequestID"), &api.SignRequest{
		CsrPEM:    api.CertificateRequest{CertificateRequest: csr},
		OTT:       ott,
		NotAfter:  api.NewTimeDuration(time.Now().Add(1 * time.Hour)),
		NotBefore: api.NewTimeDuration(time.Now().Add(-1 * time.Hour)),
	})
	assert.NoError(t, err)

	// assert a certificate was returned for the subject "test"
	if assert.NotNil(t, signResponse) {
		assert.Len(t, signResponse.CertChainPEM, 2)
		cert, err := x509.ParseCertificate(signResponse.CertChainPEM[0].Raw)
		assert.NoError(t, err)
		if assert.NotNil(t, cert) {
			assert.Equal(t, "test", cert.Subject.CommonName)
			assert.Contains(t, cert.DNSNames, "test")
		}
	}

	// done testing; stop and wait for the server to quit
	err = c.Stop()
	require.NoError(t, err)

	wg.Wait()
}

func decryptPrivateKey(t *testing.T, jwe *jose.JSONWebEncryption, pass []byte) *jose.JSONWebKey {
	t.Helper()
	d, err := jwe.Decrypt(pass)
	require.NoError(t, err)

	jwk := &jose.JSONWebKey{}
	err = json.Unmarshal(d, jwk)
	require.NoError(t, err)

	return jwk
}

func generateOTT(t *testing.T, jwk *jose.JSONWebKey, subject string) string {
	t.Helper()
	now := time.Now()

	keyID, err := jose.Thumbprint(jwk)
	require.NoError(t, err)

	opts := new(jose.SignerOptions).WithType("JWT").WithHeader("kid", keyID)
	signer, err := jose.NewSigner(jose.SigningKey{Key: jwk.Key}, opts)
	require.NoError(t, err)

	id, err := randutil.ASCII(64)
	require.NoError(t, err)

	cl := struct {
		jose.Claims
		SANS []string `json:"sans"`
	}{
		Claims: jose.Claims{
			ID:        id,
			Subject:   subject,
			Issuer:    "jwk",
			NotBefore: jose.NewNumericDate(now),
			Expiry:    jose.NewNumericDate(now.Add(time.Minute)),
			Audience:  []string{"https://127.0.0.1/1.0/sign"},
		},
		SANS: []string{subject},
	}
	raw, err := jose.Signed(signer).Claims(cl).CompactSerialize()
	require.NoError(t, err)

	return raw
}

func newAuthorizingServer(t *testing.T, mca *minica.CA) *httptest.Server {
	t.Helper()

	key, err := keyutil.GenerateDefaultSigner()
	require.NoError(t, err)

	csr, err := x509util.CreateCertificateRequest("127.0.0.1", []string{"127.0.0.1"}, key)
	require.NoError(t, err)

	crt, err := mca.SignCSR(csr)
	require.NoError(t, err)

	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if assert.Equal(t, "signRequestID", r.Header.Get("X-Request-Id")) {
			json.NewEncoder(w).Encode(struct{ Allow bool }{Allow: true})
			w.WriteHeader(http.StatusOK)
			return
		}

		w.WriteHeader(http.StatusBadRequest)
	}))
	trustedRoots := x509.NewCertPool()
	trustedRoots.AddCert(mca.Root)

	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{
			{
				Certificate: [][]byte{crt.Raw, mca.Intermediate.Raw},
				PrivateKey:  key,
				Leaf:        crt,
			},
		},
		ClientCAs:  trustedRoots,
		ClientAuth: tls.RequireAndVerifyClientCert,
		ServerName: "localhost",
	}

	return srv
}
