/*
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 openstacktasks

import (
	"context"
	"fmt"
	"strconv"
	"strings"

	l3floatingip "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports"

	"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/attachinterfaces"
	"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs"
	"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
	"k8s.io/klog/v2"
	"k8s.io/kops/pkg/truncate"
	"k8s.io/kops/pkg/wellknownservices"
	"k8s.io/kops/upup/pkg/fi"
	"k8s.io/kops/upup/pkg/fi/cloudup/openstack"
)

// +kops:fitask
type Instance struct {
	ID               *string
	Name             *string
	GroupName        *string
	Port             *Port
	Region           *string
	Flavor           *string
	Image            *string
	SSHKey           *string
	ServerGroup      *ServerGroup
	Role             *string
	UserData         fi.Resource
	Metadata         map[string]string
	AvailabilityZone *string
	SecurityGroups   []string
	FloatingIP       *FloatingIP
	ConfigDrive      *bool
	Status           *string

	Lifecycle fi.Lifecycle

	// WellKnownServices indicates which services are supported by this resource.
	// This field is internal and is not rendered to the cloud.
	WellKnownServices []wellknownservices.WellKnownService
}

var (
	_ fi.CloudupTask            = &Instance{}
	_ fi.HasAddress             = &Instance{}
	_ fi.CloudupHasDependencies = &Instance{}
)

// Constants for truncating Tags
const MAX_TAG_LENGTH_OPENSTACK = 60

var TRUNCATE_OPT = truncate.TruncateStringOptions{
	MaxLength:     MAX_TAG_LENGTH_OPENSTACK,
	AlwaysAddHash: false,
	HashLength:    6,
}

// GetDependencies returns the dependencies of the Instance task
func (e *Instance) GetDependencies(tasks map[string]fi.CloudupTask) []fi.CloudupTask {
	var deps []fi.CloudupTask
	for _, task := range tasks {
		if _, ok := task.(*ServerGroup); ok {
			deps = append(deps, task)
		}
		if _, ok := task.(*Port); ok {
			deps = append(deps, task)
		}
		if _, ok := task.(*FloatingIP); ok {
			deps = append(deps, task)
		}
	}

	if e.UserData != nil {
		deps = append(deps, fi.FindDependencies(tasks, e.UserData)...)
	}

	return deps
}

var _ fi.CompareWithID = &Instance{}

func (e *Instance) CompareWithID() *string {
	return e.ID
}

// GetWellKnownServices implements fi.HasAddress::GetWellKnownServices.
// It indicates which services we support with this instance.
func (e *Instance) GetWellKnownServices() []wellknownservices.WellKnownService {
	return e.WellKnownServices
}

func (e *Instance) FindAddresses(context *fi.CloudupContext) ([]string, error) {
	cloud := context.T.Cloud.(openstack.OpenstackCloud)
	if e.Port == nil {
		return nil, nil
	}

	ports, err := cloud.GetPort(fi.ValueOf(e.Port.ID))
	if err != nil {
		return nil, err
	}

	for _, port := range ports.FixedIPs {
		return []string{port.IPAddress}, nil
	}

	return nil, nil
}

// filterInstancePorts tries to get all ports of an instance tagged with the cluster name.
// If no tagged ports are found it will return all ports of the instance, to not change the legacy behavior when there weren't tagged ports
func filterInstancePorts(allPorts []ports.Port, clusterName string) []ports.Port {
	clusterNameTag := truncate.TruncateString(fmt.Sprintf("%s=%s", openstack.TagClusterName, clusterName), TRUNCATE_OPT)

	var taggedPorts []ports.Port

	for _, port := range allPorts {
		for _, tag := range port.Tags {
			if tag == clusterNameTag {
				taggedPorts = append(taggedPorts, port)
				break
			}
		}
	}

	if len(taggedPorts) == 0 {
		return allPorts
	}

	return taggedPorts
}

func (e *Instance) Find(c *fi.CloudupContext) (*Instance, error) {
	if e == nil || e.Name == nil {
		return nil, nil
	}
	cloud := c.T.Cloud.(openstack.OpenstackCloud)

	serverList, err := cloud.ListInstances(servers.ListOpts{
		Name: fmt.Sprintf("^%s", fi.ValueOf(e.GroupName)),
	})
	if err != nil {
		return nil, fmt.Errorf("error listing servers: %v", err)
	}

	var filteredList []servers.Server
	for _, server := range serverList {
		val, ok := server.Metadata["k8s"]
		if !ok || val != fi.ValueOf(e.ServerGroup.ClusterName) {
			continue
		}
		metadataName := ""
		val, ok = server.Metadata[openstack.TagKopsName]
		if ok {
			metadataName = val
		}
		// name or metadata tag should match to instance name
		// this is needed for backwards compatibility
		if server.Name == fi.ValueOf(e.Name) || metadataName == fi.ValueOf(e.Name) {
			filteredList = append(filteredList, server)
		}
	}

	if filteredList == nil {
		return nil, nil
	}
	if len(filteredList) > 1 {
		return nil, fmt.Errorf("Multiple servers found with name %s", fi.ValueOf(e.Name))
	}

	server := filteredList[0]
	actual := &Instance{
		ID:               fi.PtrTo(server.ID),
		Name:             e.Name,
		SSHKey:           fi.PtrTo(server.KeyName),
		Lifecycle:        e.Lifecycle,
		Metadata:         server.Metadata,
		Role:             fi.PtrTo(server.Metadata["KopsRole"]),
		AvailabilityZone: e.AvailabilityZone,
		GroupName:        e.GroupName,
		ConfigDrive:      e.ConfigDrive,
		Status:           fi.PtrTo(server.Status),
	}

	ports, err := cloud.ListPorts(ports.ListOpts{
		DeviceID: server.ID,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to fetch port for instance %v: %v", server.ID, err)
	}

	ports = filterInstancePorts(ports, fi.ValueOf(e.ServerGroup.ClusterName))

	if len(ports) == 1 {
		port := ports[0]
		porttask, err := newPortTaskFromCloud(cloud, e.Lifecycle, &port, nil)
		if err != nil {
			return nil, fmt.Errorf("failed to fetch port for instance %v: %v", server.ID, err)
		}
		actual.Port = porttask

	} else if len(ports) > 1 {
		return nil, fmt.Errorf("found more than one port for instance %v", server.ID)
	}

	if e.FloatingIP != nil && e.Port != nil {
		fips, err := cloud.ListL3FloatingIPs(l3floatingip.ListOpts{
			PortID: fi.ValueOf(e.Port.ID),
		})
		if err != nil {
			return nil, fmt.Errorf("failed to fetch floating ips for instance %v: %v", server.ID, err)
		}

		if len(fips) == 1 {
			fip := fips[0]
			fipTask := &FloatingIP{
				ID:   fi.PtrTo(fip.ID),
				Name: fi.PtrTo(fip.Description),
			}

			actual.FloatingIP = fipTask
		} else if len(fips) > 1 {
			return nil, fmt.Errorf("found more than one floating ip for instance %v", server.ID)
		}
	}

	// Avoid flapping
	e.ID = actual.ID
	e.Status = fi.PtrTo(activeStatus)
	actual.WellKnownServices = e.WellKnownServices

	// Immutable fields
	actual.Flavor = e.Flavor
	actual.Image = e.Image
	actual.UserData = e.UserData
	actual.Region = e.Region
	actual.SSHKey = e.SSHKey
	actual.ServerGroup = e.ServerGroup

	return actual, nil
}

func (e *Instance) Run(c *fi.CloudupContext) error {
	return fi.CloudupDefaultDeltaRunMethod(e, c)
}

func (_ *Instance) CheckChanges(a, e, changes *Instance) error {
	if a == nil {
		if e.Name == nil {
			return fi.RequiredField("Name")
		}
	} else {
		if changes.ID != nil {
			return fi.CannotChangeField("ID")
		}
		if changes.Name != nil {
			return fi.CannotChangeField("Name")
		}
	}
	return nil
}

func (_ *Instance) ShouldCreate(a, e, changes *Instance) (bool, error) {
	if a == nil {
		return true, nil
	}
	if fi.ValueOf(a.Status) == errorStatus {
		return true, nil
	}
	if changes.Port != nil {
		return true, nil
	}
	if changes.FloatingIP != nil {
		return true, nil
	}

	return false, nil
}

// generateInstanceName generates name for the instance
// the instance format is [GroupName]-[6 character hash]
func generateInstanceName(e *Instance) (string, error) {
	secret, err := fi.CreateSecret()
	if err != nil {
		return "", err
	}

	hash, err := secret.AsString()
	if err != nil {
		return "", err
	}

	return strings.ToLower(fmt.Sprintf("%s-%s", fi.ValueOf(e.GroupName), hash[0:6])), nil
}

func (_ *Instance) RenderOpenstack(t *openstack.OpenstackAPITarget, a, e, changes *Instance) error {
	cloud := t.Cloud

	if a != nil && fi.ValueOf(a.Status) == errorStatus {
		klog.V(2).Infof("Delete previously failed server: %s\n", fi.ValueOf(a.ID))
		cloud.DeleteInstanceWithID(fi.ValueOf(a.ID))
	}

	if a == nil || fi.ValueOf(a.Status) == errorStatus {
		serverName, err := generateInstanceName(e)
		if err != nil {
			return err
		}
		klog.V(2).Infof("Creating Instance with name: %q", serverName)

		imageName := fi.ValueOf(e.Image)
		image, err := cloud.GetImage(imageName)
		if err != nil {
			return fmt.Errorf("failed to find image %v: %v", imageName, err)
		}

		flavorName := fi.ValueOf(e.Flavor)
		flavor, err := cloud.GetFlavor(flavorName)
		if err != nil {
			return fmt.Errorf("failed to find flavor %v: %v", flavorName, err)
		}

		opt := servers.CreateOpts{
			Name:      serverName,
			ImageRef:  image.ID,
			FlavorRef: flavor.ID,
			Networks: []servers.Network{
				{
					Port: fi.ValueOf(e.Port.ID),
				},
			},
			Metadata:       e.Metadata,
			SecurityGroups: e.SecurityGroups,
			ConfigDrive:    e.ConfigDrive,
		}
		if e.UserData != nil {
			bytes, err := fi.ResourceAsBytes(e.UserData)
			if err != nil {
				return err
			}
			opt.UserData = bytes
		}
		if e.AvailabilityZone != nil {
			opt.AvailabilityZone = fi.ValueOf(e.AvailabilityZone)
		}
		if opt, err = includeBootVolumeOptions(t, e, opt); err != nil {
			return err
		}

		keyext := keypairs.CreateOptsExt{
			CreateOptsBuilder: opt,
			KeyName:           openstackKeyPairName(fi.ValueOf(e.SSHKey)),
		}

		schedulerHints := servers.SchedulerHintOpts{Group: *e.ServerGroup.ID}

		v, err := t.Cloud.CreateInstance(keyext, schedulerHints, fi.ValueOf(e.Port.ID))
		if err != nil {
			return fmt.Errorf("Error creating instance: %v", err)
		}
		e.ID = fi.PtrTo(v.ID)

		if e.FloatingIP != nil {
			err = associateFloatingIP(t, e)
			if err != nil {
				return err
			}
		}

		klog.V(2).Infof("Creating a new Openstack instance, id=%s", v.ID)

		return nil
	}
	if changes.Port != nil {
		_, err := attachinterfaces.Create(context.TODO(), cloud.ComputeClient(), fi.ValueOf(e.ID), attachinterfaces.CreateOpts{
			PortID: fi.ValueOf(changes.Port.ID),
		}).Extract()
		if err != nil {
			return err
		}
	}
	if changes.FloatingIP != nil {
		err := associateFloatingIP(t, e)
		if err != nil {
			return err
		}
	}
	return nil
}

func associateFloatingIP(t *openstack.OpenstackAPITarget, e *Instance) error {
	client := t.Cloud.NetworkingClient()

	_, err := l3floatingip.Update(context.TODO(), client, fi.ValueOf(e.FloatingIP.ID), l3floatingip.UpdateOpts{
		PortID: e.Port.ID,
	}).Extract()
	if err != nil {
		return fmt.Errorf("failed to associated floating IP to instance %s: %v", *e.Name, err)
	}
	return nil
}

func includeBootVolumeOptions(t *openstack.OpenstackAPITarget, e *Instance, opts servers.CreateOpts) (servers.CreateOpts, error) {
	if !bootFromVolume(e.Metadata) {
		return opts, nil
	}

	i, err := t.Cloud.GetImage(fi.ValueOf(e.Image))
	if err != nil {
		return servers.CreateOpts{}, fmt.Errorf("Error getting image information: %v", err)
	}

	blockDevice := servers.BlockDevice{
		BootIndex:           0,
		DeleteOnTermination: true,
		DestinationType:     "volume",
		SourceType:          "image",
		UUID:                i.ID,
		VolumeSize:          i.MinDiskGigabytes,
	}

	if s, ok := e.Metadata[openstack.BOOT_VOLUME_SIZE]; ok {
		i, err := strconv.ParseInt(s, 10, 64)
		if err != nil {
			return servers.CreateOpts{}, fmt.Errorf("Invalid value for %v: %v", openstack.BOOT_VOLUME_SIZE, err)
		}

		blockDevice.VolumeSize = int(i)
	}

	opts.BlockDevice = []servers.BlockDevice{blockDevice}
	return opts, nil
}

func bootFromVolume(m map[string]string) bool {
	v, ok := m[openstack.BOOT_FROM_VOLUME]
	if !ok {
		return false
	}

	switch v {
	case "true", "enabled":
		return true
	default:
		return false
	}
}
