/*
Copyright 2019 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package model

import (
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"sort"

	"k8s.io/klog/v2"
	"k8s.io/kops/pkg/apis/kops"
	"k8s.io/kops/pkg/apis/nodeup"
	"k8s.io/kops/pkg/assets"
	"k8s.io/kops/pkg/model/resources"
	"k8s.io/kops/pkg/nodemodel/wellknownassets"
	"k8s.io/kops/pkg/wellknownservices"
	"k8s.io/kops/upup/pkg/fi"
	"k8s.io/kops/upup/pkg/fi/fitasks"
	"k8s.io/kops/upup/pkg/fi/utils"
	"k8s.io/kops/util/pkg/architectures"
	"k8s.io/kops/util/pkg/vfs"
)

type NodeUpConfigBuilder interface {
	BuildConfig(ig *kops.InstanceGroup, wellKnownAddresses WellKnownAddresses, keysets map[string]*fi.Keyset) (*nodeup.Config, *nodeup.BootConfig, error)
}

// WellKnownAddresses holds known addresses for well-known services
type WellKnownAddresses map[wellknownservices.WellKnownService][]string

// BootstrapScriptBuilder creates the bootstrap script
type BootstrapScriptBuilder struct {
	*KopsModelContext
	Lifecycle           fi.Lifecycle
	NodeUpAssets        map[architectures.Architecture]*assets.MirroredAsset
	NodeUpConfigBuilder NodeUpConfigBuilder
}

type BootstrapScript struct {
	Name      string
	Lifecycle fi.Lifecycle
	cluster   *kops.Cluster
	ig        *kops.InstanceGroup
	builder   *BootstrapScriptBuilder
	resource  fi.CloudupTaskDependentResource

	// hasAddressTasks holds fi.HasAddress tasks, that contribute well-known services.
	hasAddressTasks []fi.HasAddress

	// caTasks hold the CA tasks, for dependency analysis.
	caTasks map[string]*fitasks.Keypair

	// nodeupConfig contains the nodeup config.
	nodeupConfig fi.CloudupTaskDependentResource
	// nodeupScript contains the nodeup bootstrap script, for use with Karpenter.
	nodeupScript fi.CloudupTaskDependentResource
}

var (
	_ fi.CloudupTask            = &BootstrapScript{}
	_ fi.HasName                = &BootstrapScript{}
	_ fi.CloudupHasDependencies = &BootstrapScript{}
)

// kubeEnv returns the boot config for the instance group
func (b *BootstrapScript) kubeEnv(cluster *kops.Cluster, ig *kops.InstanceGroup, c *fi.CloudupContext) (*nodeup.BootConfig, error) {
	wellKnownAddresses := make(WellKnownAddresses)

	for _, hasAddress := range b.hasAddressTasks {
		addresses, err := hasAddress.FindAddresses(c)
		if err != nil {
			return nil, fmt.Errorf("error finding address for %v: %v", hasAddress, err)
		}
		if len(addresses) == 0 {
			// Such tasks won't have an address in dry-run mode, until the resource is created
			klog.V(2).Infof("Task did not have an address: %v", hasAddress)
			continue
		}

		klog.V(8).Infof("Resolved alternateNames %q for %q", addresses, hasAddress)

		for _, wellKnownService := range hasAddress.GetWellKnownServices() {
			wellKnownAddresses[wellKnownService] = append(wellKnownAddresses[wellKnownService], addresses...)
		}
	}

	for k := range wellKnownAddresses {
		sort.Strings(wellKnownAddresses[k])
	}

	keysets := make(map[string]*fi.Keyset)
	for _, caTask := range b.caTasks {
		name := *caTask.Name
		keyset := caTask.Keyset()
		if keyset == nil {
			return nil, fmt.Errorf("failed to get keyset from %q", name)
		}
		keysets[name] = keyset
	}
	config, bootConfig, err := b.builder.NodeUpConfigBuilder.BuildConfig(ig, wellKnownAddresses, keysets)
	if err != nil {
		return nil, err
	}

	configData, err := utils.YamlMarshal(config)
	if err != nil {
		return nil, fmt.Errorf("error converting nodeup config to yaml: %v", err)
	}
	sum256 := sha256.Sum256(configData)
	bootConfig.NodeupConfigHash = base64.StdEncoding.EncodeToString(sum256[:])
	b.nodeupConfig.Resource = fi.NewBytesResource(configData)

	if ig.Spec.Manager == kops.InstanceManagerKarpenter {
		assetBuilder := assets.NewAssetBuilder(vfs.NewVFSContext(), cluster.Spec.Assets, false)
		nodeUpAssets := make(map[architectures.Architecture]*assets.MirroredAsset)
		for _, arch := range architectures.GetSupported() {
			asset, err := wellknownassets.NodeUpAsset(assetBuilder, arch)
			if err != nil {
				return nil, err
			}
			nodeUpAssets[arch] = asset
		}

		var nodeupScript resources.NodeUpScript
		nodeupScript.NodeUpAssets = nodeUpAssets
		nodeupScript.BootConfig = bootConfig

		nodeupScript.WithEnvironmentVariables(cluster, ig)
		nodeupScript.WithProxyEnv(cluster)
		nodeupScript.WithSysctls()

		nodeupScript.CompressUserData = fi.ValueOf(ig.Spec.CompressUserData)

		nodeupScript.CloudProvider = string(cluster.GetCloudProvider())

		if err := nodeupScript.ResolveS3Region(c.Context(), vfs.Context); err != nil {
			return nil, err
		}

		scriptResource, err := nodeupScript.Build()
		if err != nil {
			return nil, err
		}
		scriptData, err := fi.ResourceAsBytes(scriptResource)
		if err != nil {
			return nil, err
		}
		b.nodeupScript.Resource = fi.NewBytesResource(scriptData)
	}

	return bootConfig, nil
}

func KeypairNamesForInstanceGroup(cluster *kops.Cluster, ig *kops.InstanceGroup) []string {
	keypairs := []string{"kubernetes-ca"}

	// Add keypairs for default etcd clusters (main, events, and leases, not cilium)
	if ig.IsControlPlane() {
		for _, etcdCluster := range cluster.Spec.EtcdClusters {
			k := etcdCluster.Name
			if k != "events" && k != "main" && k != "leases" {
				// Likely cilium
				continue
			}
			keypairs = append(keypairs, "etcd-manager-ca-"+k, "etcd-peers-ca-"+k)
			// The client ca certificate is shared between events, main, and leases etcd clusters
			keypairs = append(keypairs, "etcd-clients-ca")
		}
	}

	// Add keypair for discovery service CA if enabled
	if ig.IsControlPlane() {
		if cluster.Spec.ServiceAccountIssuerDiscovery != nil &&
			cluster.Spec.ServiceAccountIssuerDiscovery.DiscoveryService != nil &&
			cluster.Spec.ServiceAccountIssuerDiscovery.DiscoveryService.URL != "" {
			keypairs = append(keypairs, fi.DiscoveryCAID)
		}
	}

	if ig.RunsAPIServer() {
		keypairs = append(keypairs, "apiserver-aggregator-ca", "service-account", "etcd-clients-ca")
	}

	// Add keypairs for cilium etcd clusters (not the default etcd clusters)
	for _, etcdCluster := range cluster.Spec.EtcdClusters {
		k := etcdCluster.Name
		if k == "events" || k == "main" || k == "leases" {
			// Not cilium
			continue
		}

		keypairs = append(keypairs, "etcd-manager-ca-"+k, "etcd-peers-ca-"+k, "etcd-clients-ca-"+k)
	}

	if ig.IsBastion() {
		keypairs = nil
	}

	return keypairs
}

// ResourceNodeUp generates and returns a nodeup (bootstrap) script from a
// template file, substituting in specific env vars & cluster spec configuration
func (b *BootstrapScriptBuilder) ResourceNodeUp(c *fi.CloudupModelBuilderContext, ig *kops.InstanceGroup) (fi.Resource, error) {
	keypairNames := KeypairNamesForInstanceGroup(b.Cluster, ig)

	if ig.IsBastion() {
		// Bastions can have AdditionalUserData, but if there isn't any skip this part
		if len(ig.Spec.AdditionalUserData) == 0 {
			return nil, nil
		}
	}

	keypairTasks := map[string]*fitasks.Keypair{}
	for _, keypair := range keypairNames {
		caTaskObject, found := c.Tasks["Keypair/"+keypair]
		if !found {
			return nil, fmt.Errorf("keypair/%s task not found", keypair)
		}
		keypairTasks[keypair] = caTaskObject.(*fitasks.Keypair)
	}

	task := &BootstrapScript{
		Name:      ig.Name,
		Lifecycle: b.Lifecycle,
		cluster:   b.Cluster,
		ig:        ig,
		builder:   b,
		caTasks:   keypairTasks,
	}
	task.resource.Task = task
	task.nodeupConfig.Task = task
	task.nodeupScript.Task = task
	c.AddTask(task)

	c.AddTask(&fitasks.ManagedFile{
		Name:      new("nodeupconfig-" + ig.Name),
		Lifecycle: b.Lifecycle,
		Location:  new("igconfig/" + ig.Spec.Role.ToLowerString() + "/" + ig.Name + "/nodeupconfig.yaml"),
		Contents:  &task.nodeupConfig,
	})
	if ig.Spec.Manager == kops.InstanceManagerKarpenter {
		c.AddTask(&fitasks.ManagedFile{
			Name:      new("nodeupscript-" + ig.Name),
			Lifecycle: b.Lifecycle,
			Location:  new("igconfig/" + ig.Spec.Role.ToLowerString() + "/" + ig.Name + "/nodeupscript.sh"),
			Contents:  &task.nodeupScript,
		})
	}
	return &task.resource, nil
}

func (b *BootstrapScript) GetName() *string {
	return &b.Name
}

func (b *BootstrapScript) GetDependencies(tasks map[string]fi.CloudupTask) []fi.CloudupTask {
	var deps []fi.CloudupTask

	for _, task := range tasks {
		if hasAddress, ok := task.(fi.HasAddress); ok && len(hasAddress.GetWellKnownServices()) > 0 {
			deps = append(deps, task)
			b.hasAddressTasks = append(b.hasAddressTasks, hasAddress)
		}
	}

	for _, task := range b.caTasks {
		deps = append(deps, task)
	}

	return deps
}

func (b *BootstrapScript) Run(c *fi.CloudupContext) error {
	if b.Lifecycle == fi.LifecycleIgnore {
		return nil
	}

	bootConfig, err := b.kubeEnv(b.cluster, b.ig, c)
	if err != nil {
		return err
	}

	var nodeupScript resources.NodeUpScript
	nodeupScript.NodeUpAssets = b.builder.NodeUpAssets
	nodeupScript.BootConfig = bootConfig

	nodeupScript.WithEnvironmentVariables(b.cluster, b.ig)
	nodeupScript.WithProxyEnv(b.cluster)
	nodeupScript.WithSysctls()

	nodeupScript.CompressUserData = fi.ValueOf(b.ig.Spec.CompressUserData)

	nodeupScript.CloudProvider = string(c.T.Cluster.GetCloudProvider())

	if err := nodeupScript.ResolveS3Region(c.Context(), vfs.Context); err != nil {
		return err
	}

	nodeupScriptResource, err := nodeupScript.Build()
	if err != nil {
		return err
	}

	b.resource.Resource = fi.FunctionToResource(func() ([]byte, error) {
		nodeupScript, err := fi.ResourceAsString(nodeupScriptResource)
		if err != nil {
			return nil, err
		}

		awsUserData, err := resources.AWSMultipartMIME(nodeupScript, b.ig)
		if err != nil {
			return nil, err
		}

		return []byte(awsUserData), nil
	})
	return nil
}
