/*
   Copyright The containerd 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 nerdtest

import (
	"context"
	"encoding/json"
	"fmt"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/Masterminds/semver/v3"
	"github.com/opencontainers/selinux/go-selinux"
	"gotest.tools/v3/assert"

	"github.com/containerd/containerd/v2/defaults"
	"github.com/containerd/nerdctl/mod/tigron/require"
	"github.com/containerd/nerdctl/mod/tigron/test"

	"github.com/containerd/nerdctl/v2/pkg/buildkitutil"
	"github.com/containerd/nerdctl/v2/pkg/clientutil"
	"github.com/containerd/nerdctl/v2/pkg/containerdutil"
	ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults"
	"github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat"
	"github.com/containerd/nerdctl/v2/pkg/inspecttypes/native"
	"github.com/containerd/nerdctl/v2/pkg/netutil"
	"github.com/containerd/nerdctl/v2/pkg/rootlessutil"
	"github.com/containerd/nerdctl/v2/pkg/snapshotterutil"
	"github.com/containerd/nerdctl/v2/pkg/testutil"
	"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform"
)

var BuildkitHost test.ConfigKey = "BuildkitHost"

// These are used for ambient requirements
var ipv6 test.ConfigKey = "IPv6Test"
var kubernetes test.ConfigKey = "KubeTest"
var flaky test.ConfigKey = "FlakyTest"
var only test.ConfigValue = "Only"

// These are used for down the road configuration and custom behavior inside command
var modePrivate test.ConfigKey = "PrivateMode"
var stargz test.ConfigKey = "Stargz"
var ipfs test.ConfigKey = "IPFS"
var enabled test.ConfigValue = "Enabled"

// OnlyIPv6 marks a test as suitable to be run exclusively inside an ipv6 environment
// This is an ambient requirement
var OnlyIPv6 = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		helpers.Write(ipv6, only)
		ret = environmentHasIPv6()
		if !ret {
			mess = "runner skips IPv6 compatible tests in the non-IPv6 environment"
		}
		return ret, mess
	},
}

// OnlyKubernetes marks a test as meant to be tested on Kubernetes
// This is an ambient requirement
var OnlyKubernetes = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		helpers.Write(kubernetes, only)
		if _, err := exec.LookPath("kubectl"); err != nil {
			return false, fmt.Sprintf("kubectl is not in the path: %+v", err)
		}
		ret = environmentHasKubernetes()
		if ret {
			helpers.Write(Namespace, "k8s.io")
		} else {
			mess = "runner skips Kubernetes compatible tests in the non-Kubernetes environment"
		}
		return ret, mess
	},
}

// IsFlaky marks a test as randomly failing.
// This is an ambient requirement
var IsFlaky = func(issueLink string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
			// We do not even want to get to the setup phase here
			helpers.Write(flaky, only)
			ret = environmentIsForFlaky()
			if !ret {
				mess = "runner skips flaky compatible tests in the non-flaky environment"
			}
			return ret, mess
		},
	}
}

// Docker marks a test as suitable solely for Docker and not Nerdctl
// Generally used as require.Not(nerdtest.Docker), which of course it the opposite
var Docker = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = !isTargetNerdish()
		if ret {
			mess = "current target is docker"
		} else {
			mess = "current target is not docker"
		}
		return ret, mess
	},
}

// NerdctlNeedsFixing marks a test as unsuitable to be run for Nerdctl, because of a specific known issue which
// url must be passed as an argument
var NerdctlNeedsFixing = func(issueLink string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
			ret = !isTargetNerdish()
			if ret {
				mess = "current target is docker"
			} else {
				mess = "current target is nerdctl, but we will skip as nerdctl currently has issue: " + issueLink
			}
			return ret, mess
		},
	}
}

// BrokenTest marks a test as currently broken, with explanation provided in message, along with
// additional requirements / restrictions describing what it can run on.
var BrokenTest = func(message string, req *test.Requirement) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (bool, string) {
			ret, mess := req.Check(data, helpers)
			return ret, message + "\n" + mess
		},
		Setup:   req.Setup,
		Cleanup: req.Cleanup,
	}
}

// Rootless marks a test as suitable only for the rootless environment
var Rootless = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		// Make sure we DO not return "IsRootless true" for docker
		ret = isTargetNerdish() && rootlessutil.IsRootless()
		if ret {
			mess = "environment is root-less"
		} else {
			mess = "environment is root-ful"
		}
		return ret, mess
	},
}

// Selinux marks a test as suitable only for the selinux-enabled environment
var Selinux = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = selinux.GetEnabled()
		if ret {
			mess = "selinux is enabled"
		} else {
			mess = "selinux is disabled"
		}
		return ret, mess
	},
}

// RootlessWithDetachNetNS marks a test as suitable only for rootless environment with detached netns support.
var RootlessWithDetachNetNS = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ns, err := rootlessutil.DetachedNetNS()
		if err != nil {
			return false, fmt.Sprintf("failed to check for detached netns: %+v", err)
		}
		if ns == "" {
			return false, "detached netns is not supported"
		}
		return true, "detached netns is supported"
	},
}

// RootlessWithoutDetachNetNS marks a test as suitable only for rootless environment without detached netns support.
// i.e., RootlessKit v1.
var RootlessWithoutDetachNetNS = require.All(Rootless, require.Not(RootlessWithDetachNetNS))

// Rootful marks a test as suitable only for rootful env
var Rootful = require.Not(Rootless)

// ContainerdPlugin requires that the given containerd plugin (and capabilities) is available.
var ContainerdPlugin = func(requiredType, requiredID string, requiredCaps []string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (bool, string) {
			if IsDocker() {
				return false, "ContainerdPlugin is not applicable for Docker"
			}
			stdout := helpers.Capture("info", "--mode", "native", "--format", "{{ json . }}")
			var info native.Info
			if err := json.Unmarshal([]byte(stdout), &info); err != nil {
				return false, fmt.Sprintf("failed to parse info: %v", err)
			}
			if info.Daemon == nil || info.Daemon.Plugins == nil {
				return false, fmt.Sprintf("test requires containerd plugin %q.%q", requiredType, requiredID)
			}
			for _, p := range info.Daemon.Plugins.Plugins {
				if p.Type != requiredType || p.ID != requiredID {
					continue
				}
				capMap := make(map[string]struct{}, len(p.Capabilities))
				for _, c := range p.Capabilities {
					capMap[c] = struct{}{}
				}
				for _, c := range requiredCaps {
					if _, ok := capMap[c]; !ok {
						return false, fmt.Sprintf("test requires containerd plugin %q.%q with capability %q", requiredType, requiredID, c)
					}
				}
				return true, ""
			}
			if len(requiredCaps) == 0 {
				return false, fmt.Sprintf("test requires containerd plugin %q.%q", requiredType, requiredID)
			}
			return false, fmt.Sprintf("test requires containerd plugin %q.%q with capabilities %v", requiredType, requiredID, requiredCaps)
		},
	}
}

// Info requires that `nerdctl info` satisfies the condition function passed as argument.
func Info(f func(dockercompat.Info) error) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
			stdout := helpers.Capture("info", "--format", "{{ json . }}")
			var dinf dockercompat.Info
			err := json.Unmarshal([]byte(stdout), &dinf)
			if err != nil {
				return false, fmt.Sprintf("failed to parse docker info: %v", err)
			}
			if err := f(dinf); err != nil {
				return false, err.Error()
			}
			return true, ""
		},
	}
}

// CGroup requires that cgroup is enabled
var CGroup = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = true
		mess = "cgroup is enabled"
		stdout := helpers.Capture("info", "--format", "{{ json . }}")
		var dinf dockercompat.Info
		err := json.Unmarshal([]byte(stdout), &dinf)
		assert.NilError(helpers.T(), err, "failed to parse docker info")
		switch dinf.CgroupDriver {
		case "none", "":
			ret = false
			mess = "cgroup is none"
		}
		return ret, mess
	},
}

var CgroupsAccessible = require.All(
	CGroup,
	&test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
			isRootLess := isTargetNerdish() && rootlessutil.IsRootless()
			if isRootLess {
				stdout := helpers.Capture("info", "--format", "{{ json . }}")
				var dinf dockercompat.Info
				err := json.Unmarshal([]byte(stdout), &dinf)
				assert.NilError(helpers.T(), err, "failed to parse docker info")
				return dinf.CgroupVersion == "2", "we are rootless, and cgroup version is not 2"
			}
			return true, ""
		},
	},
)

// CGroupV2 requires that cgroup is enabled and cgroup version is 2
var CGroupV2 = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = true
		mess = "cgroup is enabled"
		stdout := helpers.Capture("info", "--format", "{{ json . }}")
		var dinf dockercompat.Info
		err := json.Unmarshal([]byte(stdout), &dinf)
		assert.NilError(helpers.T(), err, "failed to parse docker info")
		switch dinf.CgroupDriver {
		case "none", "":
			ret = false
			mess = "cgroup is none"
		}
		if dinf.CgroupVersion != "2" {
			ret = false
			mess = "cgroup version is not 2"
		}
		return ret, mess
	},
}

// Soci requires that the soci snapshotter is enabled
var Soci = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = false
		mess = "soci is not enabled"
		stdout := helpers.Capture("info", "--format", "{{ json . }}")
		var dinf dockercompat.Info
		err := json.Unmarshal([]byte(stdout), &dinf)
		assert.NilError(helpers.T(), err, "failed to parse docker info")
		for _, p := range dinf.Plugins.Storage {
			if p == "soci" {
				ret = true
				mess = "soci is enabled"
			}
		}
		return ret, mess
	},
}

var Stargz = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		ret = false
		mess = "stargz is not enabled"
		stdout := helpers.Capture("info", "--format", "{{ json . }}")
		var dinf dockercompat.Info
		err := json.Unmarshal([]byte(stdout), &dinf)
		assert.NilError(helpers.T(), err, "failed to parse docker info")
		for _, p := range dinf.Plugins.Storage {
			if p == "stargz" {
				ret = true
				mess = "stargz is enabled"
			}
		}
		// Need this to happen now for Cleanups to work
		// FIXME: we should be able to access the env (at least through helpers.Command().) instead of this gym
		helpers.Write(stargz, enabled)
		return ret, mess
	},
}

// Registry marks a test as requiring a registry to be deployed
var Registry = require.All(
	// Registry requires Linux currently
	require.Linux,
	(func() *test.Requirement {
		// Provisional: see note in cleanup
		// var reg *registry.Server

		return &test.Requirement{
			Check: func(data test.Data, helpers test.Helpers) (bool, string) {
				return true, ""
			},
			Setup: func(data test.Data, helpers test.Helpers) {
				// Ensure we have registry images now, so that we can run --pull=never
				// This is useful for two reasons:
				// - if ghcr.io is out, we want to fail early
				// - when we start a large number of registries in subtests, no need to round-trip to ghcr everytime
				// This of course assumes that the subtests are NOT going to prune / rmi images
				registryImage := platform.RegistryImageStable
				helpers.Ensure("pull", "--quiet", registryImage)
				helpers.Ensure("pull", "--quiet", platform.DockerAuthImage)
				helpers.Ensure("pull", "--quiet", platform.KuboImage)
			},
			Cleanup: func(data test.Data, helpers test.Helpers) {
				// FIXME: figure out what to do with reg setup/cleanup routines
				// Provisionally, reg is available here in the closure
			},
		}
	})(),
)

// Build marks a test as suitable only if buildkitd is enabled (only tested for nerdctl obviously)
var Build = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (bool, string) {
		// FIXME: shouldn't we run buildkitd in a container? At least for testing, that would be so much easier than
		// against the host install
		ret := true
		mess := "buildkitd is enabled"

		if isTargetNerdish() {
			namespace := defaultNamespace
			if ns := helpers.Read(Namespace); ns != "" {
				namespace = string(ns)
			}

			bkHostAddr, err := buildkitutil.GetBuildkitHost(namespace)
			if err != nil {
				ret = false
				mess = fmt.Sprintf("buildkitd is not enabled: %+v", err)
				return ret, mess
			}
			// We also require the buildctl binary in the path
			_, err = exec.LookPath("buildctl")
			if err != nil {
				ret = false
				mess = fmt.Sprintf("buildctl is not in the path: %+v", err)
				return ret, mess
			}
			helpers.Write(BuildkitHost, test.ConfigValue(bkHostAddr))
		}
		return ret, mess
	},
	Cleanup: func(data test.Data, helpers test.Helpers) {
		// Previously, every build test was sequential, and was purging the build cache.
		// Running this in parallel of any other test depending on build will trash it.
		// The only way to parallelize any test involving build is indeed to disable this.
		// The price to pay is that we might get cache from another test.
		// This can be avoided individually by passing --no-cache if and when necessary
		// helpers.Anyhow("builder", "prune", "--all", "--force")
	},
}

var IPFS = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		// FIXME: we should be able to access the env (at least through helpers.Command().) instead of this gym
		helpers.Write(ipfs, enabled)
		// FIXME: this is incomplete. We obviously need a daemon running, properly configured
		return require.Binary("ipfs").Check(data, helpers)
	},
}

// Private makes a test run inside a dedicated namespace, with a private config.toml, hosts directory, and DOCKER_CONFIG path
// If the target is docker, parallelism is forcefully disabled
var Private = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		// We need this to happen NOW and not in setup, as otherwise cleanup with operate on the default namespace
		namespace := data.Identifier("private")
		helpers.Write(Namespace, test.ConfigValue(namespace))
		data.Labels().Set("_deletenamespace", namespace)
		// FIXME: is this necessary? Should NoParallel be subsumed into config?
		helpers.Write(modePrivate, enabled)
		return true, "private mode creates a dedicated namespace for nerdctl, and disable parallelism for docker"
	},

	Cleanup: func(data test.Data, helpers test.Helpers) {
		if isTargetNerdish() {
			// FIXME: there are conditions where we still have some stuff in there and this fails...
			containerList := strings.TrimSpace(helpers.Capture("ps", "-aq"))
			if containerList != "" {
				helpers.Ensure(append([]string{"rm", "-f"}, strings.Split(containerList, "\n")...)...)
			}
			helpers.Ensure("system", "prune", "-f", "--all", "--volumes")
			helpers.Anyhow("namespace", "remove", data.Labels().Get("_deletenamespace"))
		}
	},
}

// Gomodjail returns whether the binary is packed with gomodjail.
// https://github.com/AkihiroSuda/gomodjail
var Gomodjail = &test.Requirement{
	Check: func(_ test.Data, helpers test.Helpers) (ret bool, mess string) {
		// FIXME: do not rely on the filename
		ret = strings.HasSuffix(getTarget(), ".gomodjail")
		if ret {
			mess = "current target is packed with gomodjail"
		} else {
			mess = "current target is not packed with gomodjail"
		}
		return ret, mess
	},
}

var AllowModifyUserns = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		if testutil.GetAllowModifyUsers() {
			return true, "allow modify userns is enabled"
		}
		return false, "allow modify userns is disabled"
	},
}

var RemapIDs = &test.Requirement{
	Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {
		// Create a cobra command for ProcessRootCmdFlags to get globalOptions
		ctx := context.Background()
		snapshotterName := defaults.DefaultSnapshotter
		namespace := defaultNamespace
		address := defaults.DefaultAddress

		client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address)
		if err != nil {
			return false, fmt.Sprintf("failed to create client: %v", err)
		}
		defer cancel()

		caps, err := client.GetSnapshotterCapabilities(ctx, snapshotterName)
		if err != nil {
			return false, fmt.Sprintf("failed to get snapshotter capabilities: %v", err)
		}

		for _, cap := range caps {
			if cap == "remap-ids" {
				return true, "snapshotter supports ID remapping"
			}
		}
		return false, "snapshotter does not support ID remapping"
	},
}

// SociVersion returns a requirement that checks if the installed SOCI version
// meets the minimum required version
func SociVersion(minVersion string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (bool, string) {
			// Use the common CheckSociVersion function from snapshotterutil
			err := snapshotterutil.CheckSociVersion(minVersion)
			if err != nil {
				return false, err.Error()
			}
			return true, fmt.Sprintf("soci version meets minimum requirement %s", minVersion)
		},
	}
}

func ContainerdVersion(v string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (bool, string) {
			ctx := context.Background()
			namespace := defaultNamespace
			address := defaults.DefaultAddress
			client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address)
			if err != nil {
				return false, fmt.Sprintf("failed to create client: %v", err)
			}
			defer cancel()
			if sv, err := containerdutil.ServerSemVer(ctx, client); err != nil {
				return false, err.Error()
			} else if sv.LessThan(semver.MustParse(v)) {
				return false, fmt.Sprintf("`nerdctl commit --compression expects containerd %s or later, got containerd %v", v, sv)
			}
			return true, ""
		},
	}
}

// CNIFirewallVersion checks if the CNI firewall plugin version is greater than or equal to the specified version
func CNIFirewallVersion(requiredVersion string) *test.Requirement {
	return &test.Requirement{
		Check: func(data test.Data, helpers test.Helpers) (bool, string) {
			cniPath := ncdefaults.CNIPath()
			firewallPath := filepath.Join(cniPath, "firewall")
			ok, err := netutil.FirewallPluginGEQVersion(firewallPath, requiredVersion)
			if err != nil {
				return false, fmt.Sprintf("Failed to check CNI firewall version: %v", err)
			}

			if !ok {
				return false, fmt.Sprintf("CNI firewall plugin version is less than required version %s", requiredVersion)
			}

			return true, fmt.Sprintf("CNI firewall plugin version is greater than or equal to required version %s", requiredVersion)
		},
	}
}
