// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package kubeconfig

import (
	"bytes"
	"crypto/tls"
	stdlibx509 "crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"maps"
	"net/url"
	"slices"
	"strings"
	"unicode"
	"unicode/utf8"

	"k8s.io/client-go/tools/clientcmd"
	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)

// defaultNamespace is the namespace Talos always sets on the generated context.
const defaultNamespace = "default"

// ErrInvalidKubeconfig is returned when the kubeconfig doesn't match the structure Talos generates.
var ErrInvalidKubeconfig = errors.New("kubeconfig doesn't match the structure generated by Talos")

// LoadAndValidate parses the kubeconfig and validates it with [Validate].
//
// This should be used for any kubeconfig which comes back over the Talos API before it is
// used to build a Kubernetes client or written to the operator's filesystem.
func LoadAndValidate(data []byte) (*clientcmdapi.Config, error) {
	config, err := clientcmd.Load(data)
	if err != nil {
		return nil, fmt.Errorf("error parsing kubeconfig: %w", err)
	}

	if err = Validate(config); err != nil {
		return nil, err
	}

	return config, nil
}

// Validate checks that the kubeconfig matches exactly the structure [Generate] produces:
// a single cluster with the server URL and the server CA, a single user with a client
// certificate and key, and a single context tying them together.
//
//nolint:gocyclo,cyclop
func Validate(config *clientcmdapi.Config) error {
	var multiErr []error

	// Talos always generates kind/apiVersion, but clientcmd.Load strips the TypeMeta while
	// decoding, so an empty value here means "not present in the decoded config", not "not set
	// in the kubeconfig": only a mismatching value can be rejected.
	if config.Kind != "" && config.Kind != "Config" {
		multiErr = append(multiErr, fmt.Errorf("unexpected kind %q", config.Kind))
	}

	if config.APIVersion != "" && config.APIVersion != "v1" {
		multiErr = append(multiErr, fmt.Errorf("unexpected apiVersion %q", config.APIVersion))
	}

	if config.Preferences.Colors || len(config.Preferences.Extensions) > 0 {
		multiErr = append(multiErr, errors.New("top-level field \"preferences\" is not allowed"))
	}

	if len(config.Extensions) > 0 {
		multiErr = append(multiErr, errors.New("top-level field \"extensions\" is not allowed"))
	}

	clusterName, cluster, err := single(config.Clusters, "cluster")
	if err != nil {
		multiErr = append(multiErr, err)
	} else {
		multiErr = append(multiErr, validateName("cluster", clusterName))
		multiErr = append(multiErr, validateCluster(cluster)...)
	}

	authInfoName, authInfo, err := single(config.AuthInfos, "user")
	if err != nil {
		multiErr = append(multiErr, err)
	} else {
		multiErr = append(multiErr, validateName("user", authInfoName))
		multiErr = append(multiErr, validateAuthInfo(authInfo)...)
	}

	contextName, kubeContext, err := single(config.Contexts, "context")
	if err != nil {
		multiErr = append(multiErr, err)
	} else {
		multiErr = append(multiErr, validateName("context", contextName))
		multiErr = append(multiErr, validateContext(kubeContext, clusterName, authInfoName)...)

		if config.CurrentContext != contextName {
			multiErr = append(multiErr, fmt.Errorf("current-context %q doesn't match the only context %q", config.CurrentContext, contextName))
		}
	}

	if err = errors.Join(multiErr...); err != nil {
		return fmt.Errorf("%w: %w", ErrInvalidKubeconfig, err)
	}

	return nil
}

func validateCluster(cluster *clientcmdapi.Cluster) []error {
	var multiErr []error

	switch u, err := url.Parse(cluster.Server); {
	case cluster.Server == "":
		multiErr = append(multiErr, errors.New("cluster field \"server\" is not set"))
	case err != nil:
		multiErr = append(multiErr, fmt.Errorf("error parsing cluster field \"server\": %w", err))
	case u.Scheme != "https" && u.Scheme != "http":
		multiErr = append(multiErr, fmt.Errorf("unexpected cluster server URL scheme %q", u.Scheme))
	case u.Host == "":
		multiErr = append(multiErr, errors.New("cluster server URL has no host"))
	case u.User != nil:
		multiErr = append(multiErr, errors.New("cluster server URL has embedded credentials"))
	}

	if len(cluster.CertificateAuthorityData) == 0 {
		multiErr = append(multiErr, errors.New("cluster field \"certificate-authority-data\" is not set"))
	} else if err := validateCertificates(cluster.CertificateAuthorityData); err != nil {
		multiErr = append(multiErr, fmt.Errorf("invalid cluster field \"certificate-authority-data\": %w", err))
	}

	return append(multiErr, disallowed("cluster", map[string]bool{
		"certificate-authority":    cluster.CertificateAuthority != "",
		"disable-compression":      cluster.DisableCompression,
		"extensions":               len(cluster.Extensions) > 0,
		"insecure-skip-tls-verify": cluster.InsecureSkipTLSVerify,
		"proxy-url":                cluster.ProxyURL != "",
		"tls-server-name":          cluster.TLSServerName != "",
	})...)
}

func validateAuthInfo(authInfo *clientcmdapi.AuthInfo) []error {
	var multiErr []error

	switch {
	case len(authInfo.ClientCertificateData) == 0:
		multiErr = append(multiErr, errors.New("user field \"client-certificate-data\" is not set"))
	case len(authInfo.ClientKeyData) == 0:
		multiErr = append(multiErr, errors.New("user field \"client-key-data\" is not set"))
	default:
		if err := validateCertificates(authInfo.ClientCertificateData); err != nil {
			multiErr = append(multiErr, fmt.Errorf("invalid user field \"client-certificate-data\": %w", err))
		}

		// this also verifies that the key matches the certificate
		if _, err := tls.X509KeyPair(authInfo.ClientCertificateData, authInfo.ClientKeyData); err != nil {
			multiErr = append(multiErr, fmt.Errorf("invalid client certificate and key pair: %w", err))
		}
	}

	return append(multiErr, disallowed("user", map[string]bool{
		"act-as":             authInfo.Impersonate != "",
		"act-as-groups":      len(authInfo.ImpersonateGroups) > 0,
		"act-as-uid":         authInfo.ImpersonateUID != "",
		"act-as-user-extra":  len(authInfo.ImpersonateUserExtra) > 0,
		"auth-provider":      authInfo.AuthProvider != nil,
		"client-certificate": authInfo.ClientCertificate != "",
		"client-key":         authInfo.ClientKey != "",
		"exec":               authInfo.Exec != nil,
		"extensions":         len(authInfo.Extensions) > 0,
		"password":           authInfo.Password != "",
		"token":              authInfo.Token != "",
		"tokenFile":          authInfo.TokenFile != "",
		"username":           authInfo.Username != "",
	})...)
}

// validateContext validates the context; clusterName and authInfoName are empty if the
// cluster/user sections failed to validate, in which case there is nothing to cross-check against.
func validateContext(kubeContext *clientcmdapi.Context, clusterName, authInfoName string) []error {
	var multiErr []error

	if clusterName != "" && kubeContext.Cluster != clusterName {
		multiErr = append(multiErr, fmt.Errorf("context references cluster %q, expected %q", kubeContext.Cluster, clusterName))
	}

	if authInfoName != "" && kubeContext.AuthInfo != authInfoName {
		multiErr = append(multiErr, fmt.Errorf("context references user %q, expected %q", kubeContext.AuthInfo, authInfoName))
	}

	if kubeContext.Namespace != defaultNamespace {
		multiErr = append(multiErr, fmt.Errorf("unexpected context namespace %q, expected %q", kubeContext.Namespace, defaultNamespace))
	}

	return append(multiErr, disallowed("context", map[string]bool{
		"extensions": len(kubeContext.Extensions) > 0,
	})...)
}

// disallowed builds an error for each field which Talos never generates but which is set.
func disallowed(kind string, fields map[string]bool) []error {
	var multiErr []error

	for _, name := range slices.Sorted(maps.Keys(fields)) {
		if fields[name] {
			multiErr = append(multiErr, fmt.Errorf("%s field %q is not allowed", kind, name))
		}
	}

	return multiErr
}

// validateCertificates checks that data is a non-empty sequence of PEM-encoded certificates
// and nothing else.
func validateCertificates(data []byte) error {
	rest := data

	var count int

	for {
		var block *pem.Block

		if block, rest = pem.Decode(rest); block == nil {
			break
		}

		if block.Type != "CERTIFICATE" {
			return fmt.Errorf("unexpected PEM block type %q", block.Type)
		}

		if _, err := stdlibx509.ParseCertificate(block.Bytes); err != nil {
			return fmt.Errorf("error parsing certificate: %w", err)
		}

		count++
	}

	if count == 0 {
		return errors.New("no PEM-encoded certificates found")
	}

	if len(bytes.TrimSpace(rest)) > 0 {
		return errors.New("trailing data after the PEM-encoded certificates")
	}

	return nil
}

// validateName rejects names which Talos would never generate and which would end up
// in the operator's kubeconfig (and on their terminal) as-is.
func validateName(kind, name string) error {
	switch {
	case name == "":
		return fmt.Errorf("%s name is empty", kind)
	case !utf8.ValidString(name):
		return fmt.Errorf("%s name is not valid UTF-8", kind)
	case strings.ContainsFunc(name, unicode.IsControl):
		return fmt.Errorf("%s name contains control characters", kind)
	}

	return nil
}

// single returns the only entry of the map, or an error if there is not exactly one.
func single[T any](m map[string]*T, kind string) (string, *T, error) {
	if len(m) != 1 {
		return "", nil, fmt.Errorf("expected exactly one %s, got %d", kind, len(m))
	}

	for name, value := range m {
		if value == nil {
			return "", nil, fmt.Errorf("%s %q is empty", kind, name)
		}

		return name, value, nil
	}

	panic("unreachable")
}
