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

import (
	"context"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"testing"

	"github.com/containerd/containerd/v2/core/containers"
	"github.com/containerd/containerd/v2/pkg/namespaces"
	"github.com/containerd/containerd/v2/pkg/testutil"
	"github.com/containerd/continuity/fs/fstest"
	"github.com/moby/sys/user"
	"github.com/opencontainers/runtime-spec/specs-go"
)

func TestGenerateSpec(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")
	s, err := GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()})
	if err != nil {
		t.Fatal(err)
	}
	if s == nil {
		t.Fatal("GenerateSpec() returns a nil spec")
	}

	if runtime.GOOS == "linux" {
		// check for matching caps
		defaults := defaultUnixCaps()
		for _, cl := range [][]string{
			s.Process.Capabilities.Bounding,
			s.Process.Capabilities.Permitted,
			s.Process.Capabilities.Effective,
		} {
			for i := 0; i < len(defaults); i++ {
				if cl[i] != defaults[i] {
					t.Errorf("cap at %d does not match set %q != %q", i, defaults[i], cl[i])
				}
			}
		}

		// check default namespaces
		defaultNS := defaultUnixNamespaces()
		for i, ns := range s.Linux.Namespaces {
			if defaultNS[i] != ns {
				t.Errorf("ns at %d does not match set %q != %q", i, defaultNS[i], ns)
			}
		}
	} else if runtime.GOOS == "windows" {
		if s.Windows == nil {
			t.Fatal("Windows section of spec not filled in for Windows spec")
		}
	}

	// test that we don't have tty set
	if s.Process.Terminal {
		t.Error("terminal set on default process")
	}
}

func TestGenerateSpecWithPlatform(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")
	platforms := []string{"windows/amd64", "linux/amd64"}
	for _, p := range platforms {
		t.Logf("Testing platform: %s", p)
		s, err := GenerateSpecWithPlatform(ctx, nil, p, &containers.Container{ID: t.Name()})
		if err != nil {
			t.Fatalf("failed to generate spec: %v", err)
		}

		if s.Root == nil {
			t.Fatal("expected non nil Root section.")
		}
		if s.Process == nil {
			t.Fatal("expected non nil Process section.")
		}
		if p == "windows/amd64" {
			if s.Linux != nil {
				t.Fatal("expected nil Linux section")
			}
			if s.Windows == nil {
				t.Fatal("expected non nil Windows section")
			}
		} else {
			if s.Linux == nil {
				t.Fatal("expected non nil Linux section")
			}
			if runtime.GOOS == "windows" && s.Windows == nil {
				t.Fatal("expected non nil Windows section for LCOW")
			} else if runtime.GOOS != "windows" && s.Windows != nil {
				t.Fatal("expected nil Windows section")
			}
		}
	}
}

func TestSpecWithTTY(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")
	s, err := GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()}, WithTTY)
	if err != nil {
		t.Fatal(err)
	}
	if !s.Process.Terminal {
		t.Error("terminal net set WithTTY()")
	}
	if runtime.GOOS == "linux" {
		v := s.Process.Env[len(s.Process.Env)-1]
		if v != "TERM=xterm" {
			t.Errorf("xterm not set in env for TTY")
		}
	} else {
		if len(s.Process.Env) != 0 {
			t.Fatal("Windows process args should be empty by default")
		}
	}
}

func TestWithLinuxNamespace(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")
	replacedNS := specs.LinuxNamespace{Type: specs.NetworkNamespace, Path: "/var/run/netns/test"}

	var s *specs.Spec
	var err error
	if runtime.GOOS != "windows" {
		s, err = GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()}, WithLinuxNamespace(replacedNS))
	} else {
		s, err = GenerateSpecWithPlatform(ctx, nil, "linux/amd64", &containers.Container{ID: t.Name()}, WithLinuxNamespace(replacedNS))
	}
	if err != nil {
		t.Fatal(err)
	}

	defaultNS := defaultUnixNamespaces()
	found := false
	for i, ns := range s.Linux.Namespaces {
		if ns == replacedNS && !found {
			found = true
			continue
		}
		if defaultNS[i] != ns {
			t.Errorf("ns at %d does not match set %q != %q", i, defaultNS[i], ns)
		}
	}
}

func TestWithCapabilities(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")

	opts := []SpecOpts{
		WithCapabilities([]string{"CAP_SYS_ADMIN"}),
	}
	var s *specs.Spec
	var err error
	if runtime.GOOS != "windows" {
		s, err = GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()}, opts...)
	} else {
		s, err = GenerateSpecWithPlatform(ctx, nil, "linux/amd64", &containers.Container{ID: t.Name()}, opts...)
	}
	if err != nil {
		t.Fatal(err)
	}

	if len(s.Process.Capabilities.Bounding) != 1 || s.Process.Capabilities.Bounding[0] != "CAP_SYS_ADMIN" {
		t.Error("Unexpected capabilities set")
	}
	if len(s.Process.Capabilities.Effective) != 1 || s.Process.Capabilities.Effective[0] != "CAP_SYS_ADMIN" {
		t.Error("Unexpected capabilities set")
	}
	if len(s.Process.Capabilities.Permitted) != 1 || s.Process.Capabilities.Permitted[0] != "CAP_SYS_ADMIN" {
		t.Error("Unexpected capabilities set")
	}
	if len(s.Process.Capabilities.Inheritable) != 0 {
		t.Errorf("Unexpected capabilities set: length is non zero (%d)", len(s.Process.Capabilities.Inheritable))
	}
}

func TestWithCapabilitiesNil(t *testing.T) {
	t.Parallel()

	ctx := namespaces.WithNamespace(context.Background(), "testing")

	s, err := GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()},
		WithCapabilities(nil),
	)
	if err != nil {
		t.Fatal(err)
	}

	if len(s.Process.Capabilities.Bounding) != 0 {
		t.Errorf("Unexpected capabilities set: length is non zero (%d)", len(s.Process.Capabilities.Bounding))
	}
	if len(s.Process.Capabilities.Effective) != 0 {
		t.Errorf("Unexpected capabilities set: length is non zero (%d)", len(s.Process.Capabilities.Effective))
	}
	if len(s.Process.Capabilities.Permitted) != 0 {
		t.Errorf("Unexpected capabilities set: length is non zero (%d)", len(s.Process.Capabilities.Permitted))
	}
	if len(s.Process.Capabilities.Inheritable) != 0 {
		t.Errorf("Unexpected capabilities set: length is non zero (%d)", len(s.Process.Capabilities.Inheritable))
	}
}

func TestPopulateDefaultWindowsSpec(t *testing.T) {
	var (
		c   = containers.Container{ID: "TestWithDefaultSpec"}
		ctx = namespaces.WithNamespace(context.Background(), "test")
	)
	var expected Spec

	populateDefaultWindowsSpec(ctx, &expected, c.ID)
	if expected.Windows == nil {
		t.Error("Cannot populate windows Spec")
	}
}

func TestPopulateDefaultUnixSpec(t *testing.T) {
	var (
		c   = containers.Container{ID: "TestWithDefaultSpec"}
		ctx = namespaces.WithNamespace(context.Background(), "test")
	)
	var expected Spec

	populateDefaultUnixSpec(ctx, &expected, c.ID)
	if expected.Linux == nil {
		t.Error("Cannot populate Unix Spec")
	}
}

func TestWithPrivileged(t *testing.T) {
	t.Parallel()
	if runtime.GOOS == "linux" {
		// because WithPrivileged depends on CapEff in /proc/self/status
		testutil.RequiresRoot(t)
	}

	ctx := namespaces.WithNamespace(context.Background(), "testing")

	opts := []SpecOpts{
		WithCapabilities(nil),
		WithMounts([]specs.Mount{
			{Type: "cgroup", Destination: "/sys/fs/cgroup", Options: []string{"ro"}},
		}),
		WithPrivileged,
	}
	var s *specs.Spec
	var err error
	if runtime.GOOS != "windows" {
		s, err = GenerateSpec(ctx, nil, &containers.Container{ID: t.Name()}, opts...)
	} else {
		s, err = GenerateSpecWithPlatform(ctx, nil, "linux/amd64", &containers.Container{ID: t.Name()}, opts...)
	}
	if err != nil {
		t.Fatal(err)
	}

	if runtime.GOOS != "linux" {
		return
	}

	if len(s.Process.Capabilities.Bounding) == 0 {
		t.Error("Expected capabilities to be set with privileged")
	}

	var foundSys, foundCgroup bool
	for _, m := range s.Mounts {
		switch m.Type {
		case "sysfs":
			foundSys = true
			var found bool
			for _, o := range m.Options {
				switch o {
				case "ro":
					t.Errorf("Found unexpected read only %s mount", m.Type)
				case "rw":
					found = true
				}
			}
			if !found {
				t.Errorf("Did not find rw mount option for %s", m.Type)
			}
		case "cgroup":
			foundCgroup = true
			var found bool
			for _, o := range m.Options {
				switch o {
				case "ro":
					t.Errorf("Found unexpected read only %s mount", m.Type)
				case "rw":
					found = true
				}
			}
			if !found {
				t.Errorf("Did not find rw mount option for %s", m.Type)
			}
		}
	}
	if !foundSys {
		t.Error("Did not find mount for sysfs")
	}
	if !foundCgroup {
		t.Error("Did not find mount for cgroupfs")
	}
}

func TestOpenUserFile_AbsoluteSymlink(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("absolute symlink handling is only supported on non-Windows platforms")
	}

	expectedContent := []byte("root:x:0:0:root:/root:/bin/bash" + t.Name())

	root := t.TempDir()
	// Use 'continuity' library to create a directory structure simulating NixOS
	if err := fstest.Apply(
		fstest.CreateDir("/etc", 0o755),
		fstest.CreateDir("/nix/store/abcd", 0o755),
		fstest.CreateFile("/nix/store/abcd/passwd", expectedContent, 0o644),
		// /etc/passwd -> /nix/store/abcd/passwd (absolute symlink)
		fstest.Symlink("/nix/store/abcd/passwd", "/etc/passwd"),
	).Apply(root); err != nil {
		t.Fatal(err)
	}

	rootFS := os.DirFS(root)

	// Ensure the FS implements the ReadLink interface.
	// If the native os.DirFS doesn't implement it (depending on Go version),
	// wrap it in our readLinkFS helper.
	if _, ok := rootFS.(readLinker); !ok {
		t.Logf("os.DirFS does not implement ReadLink; wrapping to use ReadLink")
		rootFS = readLinkFS{root: root, fs: rootFS}
	}

	f, err := openUserFile(rootFS, "etc/passwd")
	if err != nil {
		t.Fatalf("openUserFile failed on absolute symlink: %v", err)
	}
	defer f.Close()

	content, err := io.ReadAll(f)
	if err != nil {
		t.Fatal(err)
	}
	if string(content) != string(expectedContent) {
		t.Errorf("expected content %q, got %q", string(expectedContent), string(content))
	}
}

func TestGroupLookup_AbsoluteSymlink(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("absolute symlink handling is only supported on non-Windows platforms")
	}

	expectedContent := []byte("dummygroup:x:1001:paulo\n")

	root := t.TempDir()
	if err := fstest.Apply(
		fstest.CreateDir("/etc", 0o755),
		fstest.CreateDir("/nix/store/abcd", 0o755),
		fstest.CreateFile("/nix/store/abcd/group", expectedContent, 0o644),
		fstest.Symlink("/nix/store/abcd/group", "/etc/group"),
	).Apply(root); err != nil {
		t.Fatal(err)
	}

	rootFS := os.DirFS(root)
	if _, ok := rootFS.(readLinker); !ok {
		rootFS = readLinkFS{root: root, fs: rootFS}
	}

	gid, err := GIDFromFS(rootFS, func(g user.Group) bool {
		return g.Name == "dummygroup"
	})
	if err != nil {
		t.Fatalf("GIDFromFS failed on absolute symlink: %v", err)
	}
	if gid != 1001 {
		t.Errorf("expected GID 1001, got %d", gid)
	}

	gids, err := getSupplementalGroupsFromFS(rootFS, func(g user.Group) bool {
		return g.Name == "dummygroup"
	})
	if err != nil {
		t.Fatalf("getSupplementalGroupsFromFS failed on absolute symlink: %v", err)
	}
	if len(gids) != 1 || gids[0] != 1001 {
		t.Errorf("expected supplemental GIDs [1001], got %v", gids)
	}
}

// Helpers for testing ReadLink support
type readLinkFS struct {
	root string
	fs   fs.FS
}

func (r readLinkFS) Open(name string) (fs.File, error) {
	return r.fs.Open(name)
}

func (r readLinkFS) ReadLink(name string) (string, error) {
	// Force link reading using the actual path on disk
	return os.Readlink(filepath.Join(r.root, filepath.FromSlash(name)))
}
