package container

import (
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"runtime"
	"strings"
	"testing"
	"time"

	cerrdefs "github.com/containerd/errdefs"
	"github.com/docker/docker/api/types"
	containertypes "github.com/docker/docker/api/types/container"
	"github.com/docker/docker/integration/internal/build"
	"github.com/docker/docker/integration/internal/container"
	"github.com/docker/docker/testutil/fakecontext"
	req "github.com/docker/docker/testutil/request"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
	"gotest.tools/v3/skip"
)

// TestExecWithCloseStdin adds case for moby#37870 issue.
func TestExecWithCloseStdin(t *testing.T) {
	skip.If(t, testEnv.RuntimeIsWindowsContainerd(), "FIXME. Hang on Windows + containerd combination")
	ctx := setupTest(t)

	apiClient := testEnv.APIClient()

	// run top with detached mode
	cID := container.Run(ctx, t, apiClient)

	const expected = "closeIO"
	execResp, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
		AttachStdin:  true,
		AttachStdout: true,
		Cmd:          []string{"sh", "-c", "cat && echo " + expected},
	})
	assert.NilError(t, err)

	resp, err := apiClient.ContainerExecAttach(ctx, execResp.ID, containertypes.ExecAttachOptions{})
	assert.NilError(t, err)
	defer resp.Close()

	// close stdin to send EOF to cat
	assert.NilError(t, resp.CloseWrite())

	var (
		waitCh = make(chan struct{})
		resCh  = make(chan struct {
			content string
			err     error
		}, 1)
	)

	go func() {
		close(waitCh)
		defer close(resCh)
		r, err := io.ReadAll(resp.Reader)

		resCh <- struct {
			content string
			err     error
		}{
			content: string(r),
			err:     err,
		}
	}()

	<-waitCh
	select {
	case <-time.After(3 * time.Second):
		t.Fatal("failed to read the content in time")
	case got := <-resCh:
		assert.NilError(t, got.err)

		// NOTE: using Contains because no-tty's stream contains UX information
		// like size, stream type.
		assert.Assert(t, is.Contains(got.content, expected))
	}
}

func TestExec(t *testing.T) {
	ctx := setupTest(t)
	apiClient := testEnv.APIClient()

	cID := container.Run(ctx, t, apiClient, container.WithTty(true), container.WithWorkingDir("/root"))

	id, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
		WorkingDir:   "/tmp",
		Env:          []string{"FOO=BAR"},
		AttachStdout: true,
		Cmd:          []string{"sh", "-c", "env"},
	})
	assert.NilError(t, err)

	inspect, err := apiClient.ContainerExecInspect(ctx, id.ID)
	assert.NilError(t, err)
	assert.Check(t, is.Equal(inspect.ExecID, id.ID))

	resp, err := apiClient.ContainerExecAttach(ctx, id.ID, containertypes.ExecAttachOptions{})
	assert.NilError(t, err)
	defer resp.Close()
	r, err := io.ReadAll(resp.Reader)
	assert.NilError(t, err)
	out := string(r)
	assert.NilError(t, err)
	expected := "PWD=/tmp"
	if testEnv.DaemonInfo.OSType == "windows" {
		expected = "PWD=C:/tmp"
	}
	assert.Check(t, is.Contains(out, expected), "exec command not running in expected /tmp working directory")
	assert.Check(t, is.Contains(out, "FOO=BAR"), "exec command not running with expected environment variable FOO")
}

func TestExecResize(t *testing.T) {
	ctx := setupTest(t)
	apiClient := testEnv.APIClient()

	cID := container.Run(ctx, t, apiClient, container.WithTty(true))
	defer container.Remove(ctx, t, apiClient, cID, containertypes.RemoveOptions{Force: true})

	cmd := []string{"top"}
	if runtime.GOOS == "windows" {
		cmd = []string{"sleep", "240"}
	}
	resp, err := apiClient.ContainerExecCreate(ctx, cID, containertypes.ExecOptions{
		Tty: true, // Windows requires a TTY for the resize to work, otherwise fails with "is not a tty: failed precondition", see https://github.com/moby/moby/pull/48665#issuecomment-2412530345
		Cmd: cmd,
	})
	assert.NilError(t, err)
	execID := resp.ID
	assert.NilError(t, err)
	err = apiClient.ContainerExecStart(ctx, execID, containertypes.ExecStartOptions{Detach: true})
	assert.NilError(t, err)

	t.Run("success", func(t *testing.T) {
		err := apiClient.ContainerExecResize(ctx, execID, containertypes.ResizeOptions{
			Height: 40,
			Width:  40,
		})
		if runtime.GOOS == "windows" && err != nil {
			// FIXME(thaJeztah): temporarily allowing test to fail on Windows: see https://github.com/moby/moby/issues/50402
			t.Log("XFAIL:", err)
			t.Skip("XFAIL: flaky test on Windows: see https://github.com/moby/moby/issues/50402")
			return
		}
		assert.NilError(t, err)

		// TODO(thaJeztah): also check if the resize happened
		//
		// Note: container inspect shows the initial size that was
		// set when creating the container. Actual resize happens in
		// containerd, and currently does not update the container's
		// config after running (but does send a "resize" event).
	})

	t.Run("invalid size", func(t *testing.T) {
		const valueNotSet = "unset"

		sizes := []struct {
			doc, height, width, expErr string
		}{
			{
				doc:    "unset height",
				height: valueNotSet,
				width:  "100",
				expErr: `invalid resize height "": invalid syntax`,
			},
			{
				doc:    "unset width",
				height: "100",
				width:  valueNotSet,
				expErr: `invalid resize width "": invalid syntax`,
			},
			{
				doc:    "empty height",
				width:  "100",
				expErr: `invalid resize height "": invalid syntax`,
			},
			{
				doc:    "empty width",
				height: "100",
				expErr: `invalid resize width "": invalid syntax`,
			},
			{
				doc:    "non-numeric height",
				height: "not-a-number",
				width:  "100",
				expErr: `invalid resize height "not-a-number": invalid syntax`,
			},
			{
				doc:    "non-numeric width",
				height: "100",
				width:  "not-a-number",
				expErr: `invalid resize width "not-a-number": invalid syntax`,
			},
			{
				doc:    "negative height",
				height: "-100",
				width:  "100",
				expErr: `invalid resize height "-100": value out of range`,
			},
			{
				doc:    "negative width",
				height: "100",
				width:  "-100",
				expErr: `invalid resize width "-100": value out of range`,
			},
			{
				doc:    "out of range height",
				height: "4294967296", // math.MaxUint32+1
				width:  "100",
				expErr: `invalid resize height "4294967296": value out of range`,
			},
			{
				doc:    "out of range width",
				height: "100",
				width:  "4294967296", // math.MaxUint32+1
				expErr: `invalid resize width "4294967296": value out of range`,
			},
		}
		for _, tc := range sizes {
			t.Run(tc.doc, func(t *testing.T) {
				// Manually creating a request here, as the APIClient would invalidate
				// these values before they're sent.
				vals := url.Values{}
				if tc.height != valueNotSet {
					vals.Add("h", tc.height)
				}
				if tc.width != valueNotSet {
					vals.Add("w", tc.width)
				}
				res, _, err := req.Post(ctx, "/exec/"+execID+"/resize?"+vals.Encode())
				assert.NilError(t, err)
				assert.Check(t, is.Equal(http.StatusBadRequest, res.StatusCode))

				var errorResponse types.ErrorResponse
				err = json.NewDecoder(res.Body).Decode(&errorResponse)
				assert.NilError(t, err)
				assert.Check(t, is.ErrorContains(errorResponse, tc.expErr))
			})
		}
	})

	t.Run("unknown execID", func(t *testing.T) {
		err = apiClient.ContainerExecResize(ctx, "no-such-exec-id", containertypes.ResizeOptions{
			Height: 40,
			Width:  40,
		})
		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
		assert.Check(t, is.ErrorContains(err, "No such exec instance: no-such-exec-id"))
	})

	t.Run("invalid state", func(t *testing.T) {
		// FIXME(thaJeztah): Windows + builtin returns a NotFound instead of a Conflict error
		//
		// When using the builtin runtime, stopping the container causes
		// the exec-resize to return a "NotFound" error, whereas with containerd
		// as runtime, it returns the expected "Conflict" error. This could be
		// either a limitation of the "builtin" runtime, or there's a bug to
		// be fixed.
		//
		// See https://github.com/moby/moby/pull/48665#issuecomment-2412579701
		//
		//  === RUN   TestExecResize/invalid_state
		//      exec_test.go:234: assertion failed: error is Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711 (errdefs.errNotFound), not errdefs.IsConflict
		//      exec_test.go:235: assertion failed: expected error to contain "is not running", got "Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711"
		//          Error response from daemon: No such exec instance: cc728a332d3f594249fb7ee9adb3bb12a59a5d1776f8f6dedc56355364361711
		skip.If(t, testEnv.DaemonInfo.OSType == "windows" && !testEnv.RuntimeIsWindowsContainerd(), "FIXME. Windows + builtin returns a NotFound instead of a Conflict error")

		err := apiClient.ContainerKill(ctx, cID, "SIGKILL")
		assert.NilError(t, err)

		err = apiClient.ContainerExecResize(ctx, execID, containertypes.ResizeOptions{
			Height: 40,
			Width:  40,
		})
		assert.Check(t, is.ErrorType(err, cerrdefs.IsConflict))
		assert.Check(t, is.ErrorContains(err, "is not running"))
	})
}

func TestExecUser(t *testing.T) {
	skip.If(t, testEnv.DaemonInfo.OSType == "windows", "FIXME. Probably needs to wait for container to be in running state.")
	ctx := setupTest(t)
	apiClient := testEnv.APIClient()

	ctrOpts := []func(*container.TestContainerConfig){
		container.WithTty(true),
		container.WithUser("1:1"),
	}
	withoutEtcGroups := container.WithImage(build.Do(ctx, t, apiClient, fakecontext.New(t, "", fakecontext.WithDockerfile("FROM busybox\nRUN rm /etc/group"))))
	withoutEtcPasswd := container.WithImage(build.Do(ctx, t, apiClient, fakecontext.New(t, "", fakecontext.WithDockerfile("FROM busybox\nRUN rm /etc/passwd"))))

	withUser := func(user string) func(options *containertypes.ExecOptions) {
		return func(options *containertypes.ExecOptions) { options.User = user }
	}

	tests := []struct {
		doc         string
		user        string
		ctrOpts     []func(*container.TestContainerConfig)
		expectedErr string
		expectedOut string
	}{
		{
			doc:         "default user",
			expectedOut: "uid=1(daemon) gid=1(daemon)",
		},
		{
			doc:         "uid",
			user:        "0",
			expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
		},
		{
			doc:         "uid gid",
			user:        "0:0",
			expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
		},
		{
			doc:         "username groupname",
			user:        "root:root",
			expectedOut: "uid=0(root) gid=0(root) groups=0(root)",
		},
		{
			doc:         "unknown user",
			user:        "no-such-user",
			expectedErr: `Error response from daemon: unable to find user no-such-user: no matching entries in passwd file`,
		},
		{
			doc:         "unknown user with gid",
			user:        "no-such-user:1",
			expectedErr: `Error response from daemon: unable to find user no-such-user: no matching entries in passwd file`,
		},
		{
			doc:         "unknown group",
			user:        "1:no-such-group",
			expectedErr: `Error response from daemon: unable to find group no-such-group: no matching entries in group file`,
		},
		{
			doc:     "missing etc/group",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
		},
		{
			doc:     "uid:gid and missing etc/group",
			user:    "0:0",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
		},
		{
			doc:     "user and missing etc/group",
			user:    "root",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcGroups},
		},
		{
			doc:         "user:gid and missing etc/group",
			user:        "root;0",
			ctrOpts:     []func(*container.TestContainerConfig){withoutEtcGroups},
			expectedErr: `Error response from daemon: unable to find user root;0: no matching entries in passwd file`,
		},
		{
			doc:         "group and missing etc/group",
			user:        "0:root",
			ctrOpts:     []func(*container.TestContainerConfig){withoutEtcGroups},
			expectedErr: `Error response from daemon: unable to find group root: no matching entries in group file`,
		},
		{
			doc:     "missing etc/passwd",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
		},
		{
			doc:     "uid:gid and missing etc/passwd",
			user:    "0:0",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
		},
		{
			doc:         "user and missing etc/passwd",
			user:        "root",
			ctrOpts:     []func(*container.TestContainerConfig){withoutEtcPasswd},
			expectedErr: `Error response from daemon: unable to find user root: no matching entries in passwd file`,
		},
		{
			doc:         "user:gid and missing etc/passwd",
			user:        "root;0",
			ctrOpts:     []func(*container.TestContainerConfig){withoutEtcPasswd},
			expectedErr: `Error response from daemon: unable to find user root;0: no matching entries in passwd file`,
		},
		{
			doc:     "group and missing etc/passwd",
			user:    "0:root",
			ctrOpts: []func(*container.TestContainerConfig){withoutEtcPasswd},
		},
	}

	for _, tc := range tests {
		t.Run(tc.doc, func(t *testing.T) {
			cID := container.Run(ctx, t, apiClient, append(ctrOpts, tc.ctrOpts...)...)
			result, err := container.Exec(ctx, apiClient, cID, []string{"id"}, withUser(tc.user))
			if tc.expectedErr != "" {
				assert.Check(t, is.Error(err, tc.expectedErr))
				assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
				assert.Check(t, is.Equal(result.Stdout(), "<nil>"))
				assert.Check(t, is.Equal(result.Stderr(), "<nil>"))
			} else {
				assert.Check(t, err)
				assert.Check(t, is.Contains(result.Stdout(), tc.expectedOut))
				assert.Check(t, is.Equal(result.Stderr(), ""))
			}
		})
	}
}

// Test that additional groups set with `--group-add` are kept on exec when the container
// also has a user set.
// (regression test for https://github.com/moby/moby/issues/46712)
func TestExecWithGroupAdd(t *testing.T) {
	skip.If(t, testEnv.DaemonInfo.OSType == "windows", "FIXME. Probably needs to wait for container to be in running state.")

	ctx := setupTest(t)
	apiClient := testEnv.APIClient()

	cID := container.Run(ctx, t, apiClient, container.WithTty(true), container.WithUser("root:root"), container.WithAdditionalGroups("staff", "wheel", "audio", "777"), container.WithCmd("sleep", "5"))

	result, err := container.Exec(ctx, apiClient, cID, []string{"id"})
	assert.NilError(t, err)

	const expected = "uid=0(root) gid=0(root) groups=0(root),10(wheel),29(audio),50(staff),777"
	assert.Check(t, is.Equal(strings.TrimSpace(result.Stdout()), expected), "exec command not keeping additional groups w/ user")
}
