package mongodb

import (
	"context"
	"fmt"
	"io/ioutil"
	"net/url"
	"os"
	paths "path"
	"path/filepath"
	"reflect"
	"sort"
	"testing"
	"time"

	"github.com/hashicorp/vault/helper/testhelpers/certhelpers"
	dbplugin "github.com/hashicorp/vault/sdk/database/dbplugin/v5"
	"github.com/ory/dockertest"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"go.mongodb.org/mongo-driver/mongo/readpref"
)

func TestInit_clientTLS(t *testing.T) {
	t.Skip("Skipping this test because CircleCI can't mount the files we need without further investigation: " +
		"https://support.circleci.com/hc/en-us/articles/360007324514-How-can-I-mount-volumes-to-docker-containers-")

	// Set up temp directory so we can mount it to the docker container
	confDir := makeTempDir(t)
	defer os.RemoveAll(confDir)

	// Create certificates for Mongo authentication
	caCert := certhelpers.NewCert(t,
		certhelpers.CommonName("test certificate authority"),
		certhelpers.IsCA(true),
		certhelpers.SelfSign(),
	)
	serverCert := certhelpers.NewCert(t,
		certhelpers.CommonName("server"),
		certhelpers.DNS("localhost"),
		certhelpers.Parent(caCert),
	)
	clientCert := certhelpers.NewCert(t,
		certhelpers.CommonName("client"),
		certhelpers.DNS("client"),
		certhelpers.Parent(caCert),
	)

	writeFile(t, paths.Join(confDir, "ca.pem"), caCert.CombinedPEM(), 0o644)
	writeFile(t, paths.Join(confDir, "server.pem"), serverCert.CombinedPEM(), 0o644)
	writeFile(t, paths.Join(confDir, "client.pem"), clientCert.CombinedPEM(), 0o644)

	// //////////////////////////////////////////////////////
	// Set up Mongo config file
	rawConf := `
net:
   tls:
      mode: preferTLS
      certificateKeyFile: /etc/mongo/server.pem
      CAFile: /etc/mongo/ca.pem
      allowInvalidHostnames: true`

	writeFile(t, paths.Join(confDir, "mongod.conf"), []byte(rawConf), 0o644)

	// //////////////////////////////////////////////////////
	// Start Mongo container
	retURL, cleanup := startMongoWithTLS(t, "latest", confDir)
	defer cleanup()

	// //////////////////////////////////////////////////////
	// Set up x509 user
	mClient := connect(t, retURL)

	setUpX509User(t, mClient, clientCert)

	// //////////////////////////////////////////////////////
	// Test
	mongo := new()

	initReq := dbplugin.InitializeRequest{
		Config: map[string]interface{}{
			"connection_url":      retURL,
			"allowed_roles":       "*",
			"tls_certificate_key": clientCert.CombinedPEM(),
			"tls_ca":              caCert.Pem,
		},
		VerifyConnection: true,
	}

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

	_, err := mongo.Initialize(ctx, initReq)
	if err != nil {
		t.Fatalf("Unable to initialize mongo engine: %s", err)
	}

	// Initialization complete. The connection was established, but we need to ensure
	// that we're connected as the right user
	whoamiCmd := map[string]interface{}{
		"connectionStatus": 1,
	}

	client, err := mongo.Connection(ctx)
	if err != nil {
		t.Fatalf("Unable to make connection to Mongo: %s", err)
	}
	result := client.Database("test").RunCommand(ctx, whoamiCmd)
	if result.Err() != nil {
		t.Fatalf("Unable to connect to Mongo: %s", err)
	}

	expected := connStatus{
		AuthInfo: authInfo{
			AuthenticatedUsers: []user{
				{
					User: fmt.Sprintf("CN=%s", clientCert.Template.Subject.CommonName),
					DB:   "$external",
				},
			},
			AuthenticatedUserRoles: []role{
				{
					Role: "readWrite",
					DB:   "test",
				},
				{
					Role: "userAdminAnyDatabase",
					DB:   "admin",
				},
			},
		},
		Ok: 1,
	}
	// Sort the AuthenticatedUserRoles because Mongo doesn't return them in the same order every time
	// Thanks Mongo! /tableflip
	sort.Sort(expected.AuthInfo.AuthenticatedUserRoles)

	actual := connStatus{}
	err = result.Decode(&actual)
	if err != nil {
		t.Fatalf("Unable to decode connection status: %s", err)
	}

	sort.Sort(actual.AuthInfo.AuthenticatedUserRoles)

	if !reflect.DeepEqual(actual, expected) {
		t.Fatalf("Actual:%#v\nExpected:\n%#v", actual, expected)
	}
}

func makeTempDir(t *testing.T) (confDir string) {
	confDir, err := ioutil.TempDir(".", "mongodb-test-data")
	if err != nil {
		t.Fatalf("Unable to make temp directory: %s", err)
	}
	// Convert the directory to an absolute path because docker needs it when mounting
	confDir, err = filepath.Abs(filepath.Clean(confDir))
	if err != nil {
		t.Fatalf("Unable to determine where temp directory is on absolute path: %s", err)
	}
	return confDir
}

func startMongoWithTLS(t *testing.T, version string, confDir string) (retURL string, cleanup func()) {
	if os.Getenv("MONGODB_URL") != "" {
		return os.Getenv("MONGODB_URL"), func() {}
	}

	pool, err := dockertest.NewPool("")
	if err != nil {
		t.Fatalf("Failed to connect to docker: %s", err)
	}
	pool.MaxWait = 30 * time.Second

	containerName := "mongo-unit-test"

	// Remove previously running container if it is still running because cleanup failed
	err = pool.RemoveContainerByName(containerName)
	if err != nil {
		t.Fatalf("Unable to remove old running containers: %s", err)
	}

	runOpts := &dockertest.RunOptions{
		Name:       containerName,
		Repository: "mongo",
		Tag:        version,
		Cmd:        []string{"mongod", "--config", "/etc/mongo/mongod.conf"},
		// Mount the directory from local filesystem into the container
		Mounts: []string{
			fmt.Sprintf("%s:/etc/mongo", confDir),
		},
	}

	resource, err := pool.RunWithOptions(runOpts)
	if err != nil {
		t.Fatalf("Could not start local mongo docker container: %s", err)
	}
	resource.Expire(30)

	cleanup = func() {
		err := pool.Purge(resource)
		if err != nil {
			t.Fatalf("Failed to cleanup local container: %s", err)
		}
	}

	uri := url.URL{
		Scheme: "mongodb",
		Host:   fmt.Sprintf("localhost:%s", resource.GetPort("27017/tcp")),
	}
	retURL = uri.String()

	// exponential backoff-retry
	err = pool.Retry(func() error {
		var err error
		ctx, _ := context.WithTimeout(context.Background(), 1*time.Minute)
		client, err := mongo.Connect(ctx, options.Client().ApplyURI(retURL))
		if err = client.Disconnect(ctx); err != nil {
			t.Fatal()
		}
		return client.Ping(ctx, readpref.Primary())
	})
	if err != nil {
		cleanup()
		t.Fatalf("Could not connect to mongo docker container: %s", err)
	}

	return retURL, cleanup
}

func connect(t *testing.T, uri string) (client *mongo.Client) {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
	if err != nil {
		t.Fatalf("Unable to make connection to Mongo: %s", err)
	}

	err = client.Ping(ctx, readpref.Primary())
	if err != nil {
		t.Fatalf("Failed to ping Mongo server: %s", err)
	}

	return client
}

func setUpX509User(t *testing.T, client *mongo.Client, cert certhelpers.Certificate) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	username := fmt.Sprintf("CN=%s", cert.Template.Subject.CommonName)

	cmd := &createUserCommand{
		Username: username,
		Roles: []interface{}{
			mongodbRole{
				Role: "readWrite",
				DB:   "test",
			},
			mongodbRole{
				Role: "userAdminAnyDatabase",
				DB:   "admin",
			},
		},
	}

	result := client.Database("$external").RunCommand(ctx, cmd)
	err := result.Err()
	if err != nil {
		t.Fatalf("Failed to create x509 user in database: %s", err)
	}
}

type connStatus struct {
	AuthInfo authInfo `bson:"authInfo"`
	Ok       int      `bson:"ok"`
}

type authInfo struct {
	AuthenticatedUsers     []user `bson:"authenticatedUsers"`
	AuthenticatedUserRoles roles  `bson:"authenticatedUserRoles"`
}

type user struct {
	User string `bson:"user"`
	DB   string `bson:"db"`
}

type role struct {
	Role string `bson:"role"`
	DB   string `bson:"db"`
}

type roles []role

func (r roles) Len() int           { return len(r) }
func (r roles) Less(i, j int) bool { return r[i].Role < r[j].Role }
func (r roles) Swap(i, j int)      { r[i], r[j] = r[j], r[i] }

// ////////////////////////////////////////////////////////////////////////////
// Writing to file
// ////////////////////////////////////////////////////////////////////////////
func writeFile(t *testing.T, filename string, data []byte, perms os.FileMode) {
	t.Helper()

	err := ioutil.WriteFile(filename, data, perms)
	if err != nil {
		t.Fatalf("Unable to write to file [%s]: %s", filename, err)
	}
}
