/*
Copyright 2022 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 hetznertasks

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"math/rand"
	"strconv"
	"strings"

	"github.com/hetznercloud/hcloud-go/v2/hcloud"
	"k8s.io/kops/upup/pkg/fi"
	"k8s.io/kops/upup/pkg/fi/cloudup/hetzner"
	"k8s.io/kops/upup/pkg/fi/cloudup/terraform"
	"k8s.io/kops/upup/pkg/fi/cloudup/terraformWriter"
)

// +kops:fitask
type ServerGroup struct {
	Name      *string
	Lifecycle fi.Lifecycle
	SSHKeys   []*SSHKey
	Network   *Network

	Count      int
	NeedUpdate []string

	Location string
	Size     string
	Image    string

	EnableIPv4 bool
	EnableIPv6 bool

	UserData fi.Resource

	Labels map[string]string
}

func (v *ServerGroup) Find(c *fi.CloudupContext) (*ServerGroup, error) {
	cloud := c.T.Cloud.(hetzner.HetznerCloud)
	client := cloud.ServerClient()

	labelSelector := []string{
		fmt.Sprintf("%s=%s", hetzner.TagKubernetesClusterName, c.T.Cluster.Name),
		fmt.Sprintf("%s=%s", hetzner.TagKubernetesInstanceGroup, fi.ValueOf(v.Name)),
	}
	listOptions := hcloud.ListOpts{
		PerPage:       50,
		LabelSelector: strings.Join(labelSelector, ","),
	}
	serverListOptions := hcloud.ServerListOpts{ListOpts: listOptions}
	servers, err := client.AllWithOpts(context.TODO(), serverListOptions)
	if err != nil {
		return nil, err
	}
	if len(servers) == 0 {
		return nil, nil
	}

	// Calculate the user-data hash
	userDataBytes, err := fi.ResourceAsBytes(v.UserData)
	if err != nil {
		return nil, err
	}
	userDataHash := SafeBytesHash(userDataBytes)

	// Add the expected user-data hash label
	v.Labels[hetzner.TagKubernetesInstanceUserData] = userDataHash

	actual := *v

	needUpdate, actualCount := v.classifyServers(servers, userDataHash)
	actual.NeedUpdate = needUpdate
	actual.Count = actualCount

	return &actual, nil
}

// classifyServers compares the real servers against the expected group template and returns the
// servers that need to be replaced (needUpdate) and the count to report as actual.
func (v *ServerGroup) classifyServers(servers []*hcloud.Server, userDataHash string) (needUpdate []string, actualCount int) {
	for _, server := range servers {
		// Ignore servers that are already labeled as needing update
		if _, ok := server.Labels[hetzner.TagKubernetesInstanceNeedsUpdate]; ok {
			continue
		}

		// Check if server matches the expected group template
		if server.Labels[hetzner.TagKubernetesInstanceUserData] != userDataHash {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
		if server.Location == nil || server.Location.Name != v.Location {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
		if server.ServerType == nil || server.ServerType.Name != v.Size {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
		if server.Image == nil || server.Image.Name != v.Image {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
		if (server.PublicNet.IPv4.IP != nil) != v.EnableIPv4 {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
		if (server.PublicNet.IPv6.IP != nil) != v.EnableIPv6 {
			needUpdate = append(needUpdate, server.Name)
			continue
		}
	}

	// Count is a floor: surplus servers are left in place.
	// Shrinking is done via `kops delete instance` or Cluster Autoscaler scale-down.
	actualCount = len(servers)
	if actualCount > v.Count {
		actualCount = v.Count
	}

	return needUpdate, actualCount
}

func (v *ServerGroup) Run(c *fi.CloudupContext) error {
	return fi.CloudupDefaultDeltaRunMethod(v, c)
}

func (_ *ServerGroup) CheckChanges(a, e, changes *ServerGroup) error {
	if e.Name == nil {
		return fi.RequiredField("Name")
	}
	if e.Location == "" {
		return fi.RequiredField("Location")
	}
	if e.Size == "" {
		return fi.RequiredField("Size")
	}
	if e.Image == "" {
		return fi.RequiredField("Image")
	}
	if e.UserData == nil {
		return fi.RequiredField("UserData")
	}
	return nil
}

func (_ *ServerGroup) RenderHetzner(t *hetzner.HetznerAPITarget, a, e, changes *ServerGroup) error {
	client := t.Cloud.ServerClient()

	if a != nil {
		// Add "kops.k8s.io/needs-update" label to servers needing update
		for _, serverName := range a.NeedUpdate {
			server, _, err := client.GetByName(context.TODO(), serverName)
			if err != nil {
				return err
			}
			if server == nil {
				continue
			}

			server.Labels[hetzner.TagKubernetesInstanceNeedsUpdate] = ""
			_, _, err = client.Update(context.TODO(), server, hcloud.ServerUpdateOpts{
				Name:   server.Name,
				Labels: server.Labels,
			})
			if err != nil {
				return err
			}
		}
	}

	actualCount := 0
	if a != nil {
		actualCount = a.Count
	}
	expectedCount := e.Count

	if actualCount >= expectedCount {
		return nil
	}

	if len(e.SSHKeys) == 0 {
		return fmt.Errorf("failed to find ssh keys for server group %q", fi.ValueOf(e.Name))
	}
	if e.Network == nil {
		return fmt.Errorf("failed to find network for server group %q", fi.ValueOf(e.Name))
	}

	userData, err := fi.ResourceAsString(e.UserData)
	if err != nil {
		return err
	}
	userDataBytes, err := fi.ResourceAsBytes(e.UserData)
	if err != nil {
		return err
	}
	userDataHash := SafeBytesHash(userDataBytes)

	networkID, err := strconv.ParseInt(fi.ValueOf(e.Network.ID), 10, 64)
	if err != nil {
		return fmt.Errorf("failed to convert network ID %q to int: %w", fi.ValueOf(e.Network.ID), err)
	}

	for i := 1; i <= expectedCount-actualCount; i++ {
		// Append a random/unique ID to the node name
		name := fmt.Sprintf("%s-%x", fi.ValueOf(e.Name), rand.Int63())

		opts := hcloud.ServerCreateOpts{
			Name:             name,
			StartAfterCreate: new(true),
			Networks: []*hcloud.Network{
				{
					ID: networkID,
				},
			},
			Location: &hcloud.Location{
				Name: e.Location,
			},
			ServerType: &hcloud.ServerType{
				Name: e.Size,
			},
			Image: &hcloud.Image{
				Name: e.Image,
			},
			UserData: userData,
			Labels:   e.Labels,
			PublicNet: &hcloud.ServerCreatePublicNet{
				EnableIPv4: e.EnableIPv4,
				EnableIPv6: e.EnableIPv6,
			},
		}

		// Add the SSH keys
		for _, sshkey := range e.SSHKeys {
			opts.SSHKeys = append(opts.SSHKeys, &hcloud.SSHKey{ID: fi.ValueOf(sshkey.ID)})
		}

		// Add the user-data hash label
		opts.Labels[hetzner.TagKubernetesInstanceUserData] = userDataHash

		_, _, err = client.Create(context.TODO(), opts)
		if err != nil {
			return err
		}
	}

	return nil
}

func SafeBytesHash(data []byte) string {
	// Calculate the SHA256 checksum of the data
	sum256 := sha256.Sum256(data)

	// Replace the unsupported chars with supported ones
	safe256 := base64.StdEncoding.EncodeToString(sum256[:])
	safe256 = strings.ReplaceAll(safe256, "+", "-")
	safe256 = strings.ReplaceAll(safe256, "/", "_")

	// Trim the unsupported "=" padding chars
	safe256 = strings.TrimRight(safe256, "=")

	return fmt.Sprintf("sha256.%s", safe256)
}

type terraformServer struct {
	Count      *int                       `cty:"count"`
	Name       *terraformWriter.Literal   `cty:"name"`
	Location   *string                    `cty:"location"`
	ServerType *string                    `cty:"server_type"`
	Image      *string                    `cty:"image"`
	SSHKeys    []*terraformWriter.Literal `cty:"ssh_keys"`
	Network    []*terraformServerNetwork  `cty:"network"`
	PublicNet  *terraformServerPublicNet  `cty:"public_net"`
	UserData   *terraformWriter.Literal   `cty:"user_data"`
	Labels     map[string]string          `cty:"labels"`
}

type terraformServerNetwork struct {
	ID         *terraformWriter.Literal `cty:"network_id"`
	IP         *string                  `cty:"ip"`
	AliasIPs   *string                  `cty:"alias_ips"`
	MACAddress *string                  `cty:"mac_address"`
}

type terraformServerPublicNet struct {
	EnableIPv4 *bool `cty:"ipv4_enabled"`
	EnableIPv6 *bool `cty:"ipv6_enabled"`
}

func (_ *ServerGroup) RenderTerraform(t *terraform.TerraformTarget, a, e, changes *ServerGroup) error {
	name := terraformWriter.LiteralWithIndex(fi.ValueOf(e.Name))
	tf := &terraformServer{
		Count:      new(e.Count),
		Name:       name,
		Location:   new(e.Location),
		ServerType: new(e.Size),
		Image:      new(e.Image),
		Network: []*terraformServerNetwork{
			{
				ID: e.Network.TerraformLink(),
			},
		},
		PublicNet: &terraformServerPublicNet{
			EnableIPv4: new(e.EnableIPv4),
			EnableIPv6: new(e.EnableIPv6),
		},
		Labels: e.Labels,
	}

	for _, sshkey := range e.SSHKeys {
		tf.SSHKeys = append(tf.SSHKeys, sshkey.TerraformLink())
	}

	if e.UserData != nil {
		data, err := fi.ResourceAsBytes(e.UserData)
		if err != nil {
			return err
		}
		if data != nil {
			tf.UserData, err = t.AddFileBytes("hcloud_server", fi.ValueOf(e.Name), "user_data", data, true)
			if err != nil {
				return err
			}
		}
	}

	return t.RenderResource("hcloud_server", fi.ValueOf(e.Name), tf)
}
