package provisioning

import (
	"context"
	"fmt"

	"github.com/pkg/errors"
	"golang.org/x/crypto/bcrypt"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	utilerrors "k8s.io/apimachinery/pkg/util/errors"
	coreclientv1 "k8s.io/client-go/kubernetes/typed/core/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

	"github.com/openshift/library-go/pkg/operator/events"
	"github.com/openshift/library-go/pkg/operator/resource/resourceapply"
)

const (
	baremetalSecretName = "metal3-mariadb-password" // #nosec
	baremetalSecretKey  = "password"
	ironicUsernameKey   = "username"
	ironicPasswordKey   = "password"
	ironicHtpasswdKey   = "htpasswd"
	ironicConfigKey     = "auth-config"
	ironicSecretName    = "metal3-ironic-password"
	ironicrpcSecretName = "metal3-ironic-rpc-password" // #nosec
	ironicrpcUsername   = "rpc-user"
	ironicUsername      = "ironic-user"
	inspectorSecretName = "metal3-ironic-inspector-password"
	inspectorUsername   = "inspector-user"
	tlsSecretName       = "metal3-ironic-tls" // #nosec
)

type shouldUpdateDataFn func(existing *corev1.Secret) (bool, error)

func doNotUpdateData(existing *corev1.Secret) (bool, error) {
	return false, nil
}

// applySecret merges objectmeta, applies data if the secret does not exist or shouldUpdateDataFn returns false.
func applySecret(client coreclientv1.SecretsGetter, recorder events.Recorder, requiredInput *corev1.Secret, shouldUpdateData shouldUpdateDataFn) error {
	needsApply := false
	existing, err := client.Secrets(requiredInput.Namespace).Get(context.TODO(), requiredInput.Name, metav1.GetOptions{})
	if apierrors.IsNotFound(err) {
		err = nil
		needsApply = true
	} else if err != nil {
		return err
	} else {
		// Allow the caller to decide whether update.
		needsApply, err = shouldUpdateData(existing)
		if err != nil {
			return err
		}
	}

	if needsApply {
		_, _, err = resourceapply.ApplySecret(client, recorder, requiredInput)
	}

	return err
}

// createMariadbPasswordSecret creates a Secret for Mariadb password
func createMariadbPasswordSecret(info *ProvisioningInfo) error {
	password, err := generateRandomPassword()
	if err != nil {
		return err
	}

	secret := &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      baremetalSecretName,
			Namespace: info.Namespace,
		},
		StringData: map[string]string{
			baremetalSecretKey: password,
		},
	}

	if err := controllerutil.SetControllerReference(info.ProvConfig, secret, info.Scheme); err != nil {
		return err
	}

	return applySecret(info.Client.CoreV1(), info.EventRecorder, secret, doNotUpdateData)
}

func createIronicSecret(info *ProvisioningInfo, name string, username string, configSection string) error {
	password, err := generateRandomPassword()
	if err != nil {
		return err
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(password), 5) // Use same cost as htpasswd default
	if err != nil {
		return err
	}
	// Change hash version from $2a$ to $2y$, as generated by htpasswd.
	// These are equivalent for our purposes.
	// Some background information about this : https://en.wikipedia.org/wiki/Bcrypt#Versioning_history
	// There was a bug 9 years ago in PHP's implementation of 2a, so they decided to call the fixed version 2y.
	// httpd decided to adopt this (if it sees 2a it uses elaborate heuristic workarounds to mitigate against the bug,
	// but 2y is assumed to not need them), but everyone else (including go) was just decided to not implement the bug in 2a.
	// The bug only affects passwords containing characters with the high bit set, i.e. not ASCII passwords generated here.

	// Anyway, Ironic implemented their own basic auth verification and originally hard-coded 2y because that's what
	// htpasswd produces (see https://review.opendev.org/738718). It is better to keep this as one day we may move the auth
	// to httpd and this would prevent triggering the workarounds.
	hash[2] = 'y'

	secret := &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      name,
			Namespace: info.Namespace,
		},
		StringData: map[string]string{
			ironicUsernameKey: username,
			ironicPasswordKey: password,
			ironicHtpasswdKey: fmt.Sprintf("%s:%s", username, hash),
			ironicConfigKey: fmt.Sprintf(`[%s]
auth_type = http_basic
username = %s
password = %s
`,
				configSection, username, password),
		},
	}

	if err := controllerutil.SetControllerReference(info.ProvConfig, secret, info.Scheme); err != nil {
		return err
	}

	return applySecret(info.Client.CoreV1(), info.EventRecorder, secret, doNotUpdateData)
}

func EnsureAllSecrets(info *ProvisioningInfo) (bool, error) {
	// Create a Secret for the Mariadb Password
	if err := createMariadbPasswordSecret(info); err != nil {
		return false, errors.Wrap(err, "failed to create Mariadb password")
	}
	// Create a Secret for the Ironic Password
	if err := createIronicSecret(info, ironicSecretName, ironicUsername, "ironic"); err != nil {
		return false, errors.Wrap(err, "failed to create Ironic password")
	}
	// Create a Secret for the Ironic RPC Password
	if err := createIronicSecret(info, ironicrpcSecretName, ironicrpcUsername, "json_rpc"); err != nil {
		return false, errors.Wrap(err, "failed to create Ironic rpc password")
	}
	// Create a Secret for the Ironic Inspector Password
	if err := createIronicSecret(info, inspectorSecretName, inspectorUsername, "inspector"); err != nil {
		return false, errors.Wrap(err, "failed to create Inspector password")
	}
	// Generate/update TLS certificate
	if err := createOrUpdateTlsSecret(info); err != nil {
		return false, errors.Wrap(err, "failed to create TLS certificate")
	}
	return false, nil // ApplySecret does not use Generation, so just return false for updated
}

func DeleteAllSecrets(info *ProvisioningInfo) error {
	var secretErrors []error
	for _, sn := range []string{baremetalSecretName, ironicSecretName, inspectorSecretName, ironicrpcSecretName} {
		if err := client.IgnoreNotFound(info.Client.CoreV1().Secrets(info.Namespace).Delete(context.Background(), sn, metav1.DeleteOptions{})); err != nil {
			secretErrors = append(secretErrors, err)
		}
	}
	return utilerrors.NewAggregate(secretErrors)
}

// createOrUpdateTlsSecret creates a Secret for the Ironic and Inspector TLS.
// It updates the secret if the existing certificate is close to expiration.
func createOrUpdateTlsSecret(info *ProvisioningInfo) error {
	cert, err := generateTlsCertificate(info.ProvConfig.Spec.ProvisioningIP)
	if err != nil {
		return err
	}

	secret := &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      tlsSecretName,
			Namespace: info.Namespace,
		},
		Data: map[string][]byte{
			corev1.TLSCertKey:       cert.certificate,
			corev1.TLSPrivateKeyKey: cert.privateKey,
		},
	}

	if err := controllerutil.SetControllerReference(info.ProvConfig, secret, info.Scheme); err != nil {
		return err
	}

	return applySecret(info.Client.CoreV1(), info.EventRecorder, secret, func(existing *corev1.Secret) (bool, error) {
		expired, err := isTlsCertificateExpired(existing.Data[corev1.TLSCertKey])
		if err != nil {
			return false, err
		}
		return expired, nil
	})
}
