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

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path"
	"reflect"
	"strings"
	"sync"
	"time"

	"k8s.io/klog/v2"
	"k8s.io/kops/upup/pkg/fi"
	"k8s.io/kops/upup/pkg/fi/nodeup/local"
	"k8s.io/kops/util/pkg/distributions"
	"k8s.io/kops/util/pkg/hashing"
)

type Package struct {
	Name string

	Version      *string `json:"version,omitempty"`
	Source       *string `json:"source,omitempty"`
	Hash         *string `json:"hash,omitempty"`
	PreventStart *bool   `json:"preventStart,omitempty"`

	// Healthy is true if the package installation did not fail
	Healthy *bool `json:"healthy,omitempty"`

	// Additional dependencies that must be installed before this package.
	// These will actually be passed together with this package to rpm/dpkg,
	// which will then figure out the correct order in which to install them.
	// This means that Deps don't get installed unless this package needs to
	// get installed.
	Deps []*Package `json:"deps,omitempty"`
}

const (
	localPackageDir             = "/var/cache/nodeup/packages/"
	containerSelinuxPackageName = "container-selinux"
	containerdPackageName       = "containerd.io"
	dockerPackageName           = "docker-ce"
)

var _ fi.NodeupHasDependencies = (*Package)(nil)

// GetDependencies computes dependencies for the package task
func (e *Package) GetDependencies(tasks map[string]fi.NodeupTask) []fi.NodeupTask {
	var deps []fi.NodeupTask

	// AptSource before we install any packages
	for _, v := range tasks {
		if _, ok := v.(*AptSource); ok {
			deps = append(deps, v)
		}
	}

	// If this package is a bare deb, install it after OS managed packages
	if !e.isOSPackage() {
		for _, v := range tasks {
			if vp, ok := v.(*Package); ok {
				if vp.isOSPackage() {
					deps = append(deps, v)
				}
			}
		}
	}

	// containerd should wait for container-selinux to be installed
	if e.Name == containerdPackageName {
		for _, v := range tasks {
			if vp, ok := v.(*Package); ok {
				if vp.Name == containerSelinuxPackageName {
					deps = append(deps, v)
				}
			}
		}
	}

	// Docker should wait for container-selinux and containerd to be installed
	if e.Name == dockerPackageName {
		for _, v := range tasks {
			if vp, ok := v.(*Package); ok {
				if vp.Name == containerSelinuxPackageName {
					deps = append(deps, v)
				}
				if vp.Name == containerdPackageName {
					deps = append(deps, v)
				}
			}
		}
	}

	return deps
}

var _ fi.HasName = (*Package)(nil)

func (f *Package) GetName() *string {
	return &f.Name
}

// isOSPackage returns true if this is an OS provided package (as opposed to a bare .deb, for example)
func (p *Package) isOSPackage() bool {
	return fi.ValueOf(p.Source) == ""
}

// String returns a string representation, implementing the Stringer interface
func (p *Package) String() string {
	return fmt.Sprintf("Package: %s", p.Name)
}

func (e *Package) Find(c *fi.NodeupContext) (*Package, error) {
	d, err := distributions.FindDistribution("/")
	if err != nil {
		return nil, fmt.Errorf("unknown or unsupported distro: %v", err)
	}

	if d.IsDebianFamily() {
		return e.findDpkg(c)
	}

	if d.IsRHELFamily() {
		return e.findYum(c)
	}

	return nil, fmt.Errorf("unsupported package system")
}

func (e *Package) findDpkg(c *fi.NodeupContext) (*Package, error) {
	args := []string{"dpkg-query", "-f", "${db:Status-Abbrev}\\t${Version}\\n", "-W", e.Name}
	human := strings.Join(args, " ")

	klog.V(2).Infof("Listing installed packages: %s", human)
	cmd := exec.Command(args[0], args[1:]...)
	output, err := cmd.CombinedOutput()
	if err != nil {
		if strings.Contains(string(output), "no packages found") {
			return nil, nil
		}
		return nil, fmt.Errorf("error listing installed packages: %v: %s", err, string(output))
	}

	installed := false
	var healthy *bool
	installedVersion := ""
	for _, line := range strings.Split(string(output), "\n") {
		if line == "" {
			continue
		}

		tokens := strings.Split(line, "\t")
		if len(tokens) != 2 {
			return nil, fmt.Errorf("error parsing dpkg-query line %q", line)
		}
		state := strings.TrimSpace(tokens[0])
		version := strings.TrimSpace(tokens[1])

		switch state {
		case "ii":
			installed = true
			installedVersion = version
			healthy = new(true)
		case "iF", "iU":
			installed = true
			installedVersion = version
			healthy = new(false)
		case "rc":
			// removed
			installed = false
		case "un":
			// unknown
			installed = false
		case "n":
			// not installed
			installed = false
		default:
			klog.Warningf("unknown package state %q for %q in line %q", state, e.Name, line)
			return nil, fmt.Errorf("unknown package state %q for %q in line %q", state, e.Name, line)
		}
	}

	if !installed {
		return nil, nil
	}

	return &Package{
		Name:    e.Name,
		Version: new(installedVersion),
		Healthy: healthy,
	}, nil
}

func (e *Package) findYum(c *fi.NodeupContext) (*Package, error) {
	args := []string{"/usr/bin/rpm", "-q", e.Name, "--queryformat", "%{NAME} %{VERSION}"}
	human := strings.Join(args, " ")

	klog.V(2).Infof("Listing installed packages: %s", human)
	cmd := exec.Command(args[0], args[1:]...)
	output, err := cmd.CombinedOutput()
	if err != nil {
		if strings.Contains(string(output), "is not installed") {
			return nil, nil
		}
		return nil, fmt.Errorf("error listing installed packages: %v: %s", err, string(output))
	}

	installed := false
	var healthy *bool
	installedVersion := ""
	for _, line := range strings.Split(string(output), "\n") {
		if line == "" {
			continue
		}

		tokens := strings.Split(line, " ")
		if len(tokens) != 2 {
			return nil, fmt.Errorf("error parsing rpm line %q", line)
		}

		name := tokens[0]
		if name != e.Name {
			return nil, fmt.Errorf("error parsing rpm line %q", line)
		}
		installed = true
		installedVersion = tokens[1]
		// If we implement unhealthy; be sure to implement repair in Render
		healthy = new(true)
	}

	if !installed {
		return nil, nil
	}

	return &Package{
		Name:    e.Name,
		Version: new(installedVersion),
		Healthy: healthy,
	}, nil
}

func (e *Package) Run(c *fi.NodeupContext) error {
	return fi.NodeupDefaultDeltaRunMethod(e, c)
}

func (_ *Package) CheckChanges(a, e, changes *Package) error {
	return nil
}

// packageManagerLock is a simple lock that prevents concurrent package manager operations
// It just avoids unnecessary failures from running e.g. concurrent apt-get installs
var packageManagerLock sync.Mutex

// packageManagerLastUpdated is the last time the package manager update was done
var packageManagerLastUpdated time.Time

func (_ *Package) RenderLocal(t *local.LocalTarget, a, e, changes *Package) error {
	// Not adding ctx to signature as RenderLocal seems to be part of a common interface
	ctx := context.TODO()
	packageManagerLock.Lock()
	defer packageManagerLock.Unlock()

	d, err := distributions.FindDistribution("/")
	if err != nil {
		return fmt.Errorf("unknown or unsupported distro: %v", err)
	}

	if a == nil || changes.Version != nil {
		klog.Infof("Installing package %q (dependencies: %v)", e.Name, e.Deps)
		var pkgs []string

		if e.Source != nil {
			// Install a deb or rpm.
			err := os.MkdirAll(localPackageDir, 0o755)
			if err != nil {
				return fmt.Errorf("error creating directories %q: %v", localPackageDir, err)
			}

			// Append file extension for local files
			var ext string
			if d.IsDebianFamily() {
				ext = ".deb"
			} else if d.IsRHELFamily() {
				ext = ".rpm"
			} else {
				return fmt.Errorf("unsupported package system")
			}

			// Download all the debs/rpms.
			pkgs = make([]string, 1+len(e.Deps))
			for i, pkg := range append([]*Package{e}, e.Deps...) {
				local := path.Join(localPackageDir, pkg.Name+ext)
				pkgs[i] = local
				var hash *hashing.Hash
				if fi.ValueOf(pkg.Hash) != "" {
					parsed, err := hashing.FromString(fi.ValueOf(pkg.Hash))
					if err != nil {
						return fmt.Errorf("error parsing hash: %v", err)
					}
					hash = parsed
				}
				_, err = fi.DownloadURL(ctx, fi.ValueOf(pkg.Source), local, hash)
				if err != nil {
					return err
				}
			}
		} else {
			pkgs = append(pkgs, e.Name)
		}

		env := os.Environ()
		if d.IsDebianFamily() {
			env = append(env, "DEBIAN_FRONTEND=noninteractive")
		}

		// If the package manager update was less than 10 minutes ago, skip updating the package list.
		if d.IsDebianFamily() && time.Since(packageManagerLastUpdated) > 10*time.Minute {
			klog.Infof("Running command apt-get update")
			cmd := exec.Command("apt-get", "update")
			cmd.Env = env
			output, err := cmd.CombinedOutput()
			if err != nil {
				return fmt.Errorf("error fetching the list of available packages: %v:\n%s", err, string(output))
			}
			// Successful package list update, updating the last updated time.
			packageManagerLastUpdated = time.Now()
		}

		var args []string
		if d.IsDebianFamily() {
			args = []string{"apt-get", "install", "--yes", "--no-install-recommends"}
		} else if d.IsRHELFamily() {

			if d.HasDNF() {
				args = []string{"/usr/bin/dnf", "install", "-y", "--setopt=install_weak_deps=False"}
			} else {
				args = []string{"/usr/bin/yum", "install", "-y"}
			}
		} else {
			return fmt.Errorf("unsupported package system")
		}
		args = append(args, pkgs...)

		klog.Infof("Running command %s", args)
		cmd := exec.Command(args[0], args[1:]...)
		cmd.Env = env
		output, err := cmd.CombinedOutput()
		if err != nil {
			// This is a bit of a hack, but if we get an error that says we need to run dpkg configure, we run it.
			// The typical cause is that we install a package that kills nodeup,
			// also killing apt-get, and then apt-get is in a bad state.
			// Typical error looks like this:
			// exit status 100: E: dpkg was interrupted, you must manually run 'dpkg --configure -a' to correct the problem.
			if strings.Contains(string(output), "dpkg --configure -a") {
				klog.Warningf("found error requiring dpkg repair: %q", string(output))
				args := []string{"dpkg", "--configure", "-a"}
				klog.Infof("Running command %s", args)
				cmd := exec.Command(args[0], args[1:]...)
				dpkgOutput, err := cmd.CombinedOutput()
				if err != nil {
					return fmt.Errorf("error running `dpkg --configure -a`: %v: %s", err, string(dpkgOutput))
				}
				// Note that we still return an error, because our package installation failed; we will retry.
			}
			return fmt.Errorf("error installing package %q: %v: %s", e.Name, err, string(output))
		}
		// Successful package install, updating the last updated time.
		packageManagerLastUpdated = time.Now()
	} else {
		if changes.Healthy != nil {
			if d.IsDebianFamily() {
				args := []string{"dpkg", "--configure", "-a"}
				klog.Infof("package is not healthy; running command %s", args)
				cmd := exec.Command(args[0], args[1:]...)
				output, err := cmd.CombinedOutput()
				if err != nil {
					return fmt.Errorf("error running `dpkg --configure -a`: %v: %s", err, string(output))
				}

				changes.Healthy = nil
			} else if d.IsRHELFamily() {
				// Not set on TagOSFamilyRHEL, we can't currently reach here anyway...
				return fmt.Errorf("package repair not supported on RHEL/CentOS")
			} else {
				return fmt.Errorf("unsupported package system")
			}
		}

		if !reflect.DeepEqual(changes, &Package{}) {
			klog.Warningf("cannot apply package changes for %q: %+v", e.Name, changes)
		}
	}

	return nil
}
