package cliplugins

import (
	"errors"
	"io"
	"os/exec"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/creack/pty"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
)

// TestPluginSocketBackwardsCompatible executes a plugin binary
// that does not connect to the CLI plugin socket, simulating
// a plugin compiled against an older version of the CLI, and
// ensures that backwards compatibility is maintained.
func TestPluginSocketBackwardsCompatible(t *testing.T) {
	run, _, cleanup := prepare(t)
	defer cleanup()

	t.Run("attached", func(t *testing.T) {
		t.Run("the plugin gets signalled if attached to a TTY", func(t *testing.T) {
			cmd := run("presocket", "test-no-socket")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)

			ptmx, err := pty.Start(command)
			assert.NilError(t, err, "failed to launch command with fake TTY")

			// send a SIGINT to the process group after 1 second, since
			// we're simulating an "attached TTY" scenario and a TTY would
			// send a signal to the process group
			go func() {
				<-time.After(time.Second)
				err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
			}()
			out, err := io.ReadAll(ptmx)
			if err != nil && !strings.Contains(err.Error(), "input/output error") {
				t.Fatal("failed to get command output")
			}

			// the plugin is attached to the TTY, so the parent process
			// ignores the received signal, and the plugin receives a SIGINT
			// as well
			assert.Equal(t, string(out), "received SIGINT\r\nexit after 3 seconds\r\n")
		})

		// ensure that we don't break plugins that attempt to read from the TTY
		// (see: https://github.com/moby/moby/issues/47073)
		// (remove me if/when we decide to break compatibility here)
		t.Run("the plugin can read from the TTY", func(t *testing.T) {
			cmd := run("presocket", "tty")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)

			ptmx, err := pty.Start(command)
			assert.NilError(t, err, "failed to launch command with fake TTY")
			_, _ = ptmx.WriteString("hello!")

			done := make(chan error)
			go func() {
				<-time.After(time.Second)
				_, err := io.ReadAll(ptmx)
				done <- err
			}()

			select {
			case cmdErr := <-done:
				if cmdErr != nil && !strings.Contains(cmdErr.Error(), "input/output error") {
					t.Fatal("failed to get command output")
				}
			case <-time.After(5 * time.Second):
				t.Fatal("timed out! plugin process probably stuck")
			}
		})
	})

	t.Run("detached", func(t *testing.T) {
		t.Run("the plugin does not get signalled", func(t *testing.T) {
			cmd := run("presocket", "test-no-socket")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
			t.Log(strings.Join(command.Args, " "))
			command.SysProcAttr = &syscall.SysProcAttr{
				Setpgid: true,
			}

			go func() {
				<-time.After(time.Second)
				// we're signalling the parent process directly and not
				// the process group, since we're testing the case where
				// the process is detached and not simulating a CTRL-C
				// from a TTY
				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
			}()
			out, err := command.CombinedOutput()
			t.Log("command output: " + string(out))
			assert.NilError(t, err, "failed to run command")

			// the plugin process does not receive a SIGINT
			// so it exits after 3 seconds and prints this message
			assert.Equal(t, string(out), "exit after 3 seconds\n")
		})

		t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
			cmd := run("presocket", "test-no-socket")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
			t.Log(strings.Join(command.Args, " "))
			command.SysProcAttr = &syscall.SysProcAttr{
				Setpgid: true,
			}

			go func() {
				<-time.After(time.Second)
				// we're signalling the parent process directly and not
				// the process group, since we're testing the case where
				// the process is detached and not simulating a CTRL-C
				// from a TTY
				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
				// TODO: look into CLI signal handling, it's currently necessary
				// to add a short delay between each signal in order for the CLI
				// process to consistently pick them all up.
				time.Sleep(50 * time.Millisecond)
				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
				time.Sleep(50 * time.Millisecond)
				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
			}()
			out, err := command.CombinedOutput()

			var exitError *exec.ExitError
			assert.Assert(t, errors.As(err, &exitError))
			assert.Check(t, exitError.Exited())
			assert.Check(t, is.Equal(exitError.ExitCode(), 1))

			// the plugin process does not receive a SIGINT and does
			// the CLI cannot cancel it over the socket, so it kills
			// the plugin process and forcefully exits
			assert.Equal(t, string(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
		})
	})
}

func TestPluginSocketCommunication(t *testing.T) {
	run, _, cleanup := prepare(t)
	defer cleanup()

	t.Run("attached", func(t *testing.T) {
		t.Run("the socket is not closed + the plugin receives a signal due to pgid", func(t *testing.T) {
			cmd := run("presocket", "test-socket")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)

			ptmx, err := pty.Start(command)
			assert.NilError(t, err, "failed to launch command with fake TTY")

			// send a SIGINT to the process group after 1 second, since
			// we're simulating an "attached TTY" scenario and a TTY would
			// send a signal to the process group
			go func() {
				<-time.After(time.Second)
				err := syscall.Kill(-command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal process group")
			}()
			out, err := io.ReadAll(ptmx)
			if err != nil && !strings.Contains(err.Error(), "input/output error") {
				t.Fatal("failed to get command output")
			}

			// the plugin is attached to the TTY, so the parent process
			// ignores the received signal, and the plugin receives a SIGINT
			// as well
			assert.Equal(t, string(out), "received SIGINT\r\nexit after 3 seconds\r\n")
		})
	})

	t.Run("detached", func(t *testing.T) {
		t.Run("the plugin does not get signalled", func(t *testing.T) {
			cmd := run("presocket", "test-socket")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
			command.SysProcAttr = &syscall.SysProcAttr{
				Setpgid: true,
			}

			// send a SIGINT to the process group after 1 second
			go func() {
				<-time.After(time.Second)
				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal CLI process")
			}()
			out, err := command.CombinedOutput()

			var exitError *exec.ExitError
			assert.Assert(t, errors.As(err, &exitError))
			assert.Check(t, exitError.Exited())
			assert.Check(t, is.Equal(exitError.ExitCode(), 2))

			// the plugin does not get signalled, but it does get its
			// context canceled by the CLI through the socket
			const expected = "test-socket: exiting after context was done"
			actual := strings.TrimSpace(string(out))
			assert.Check(t, is.Equal(actual, expected))
		})

		t.Run("the main CLI exits after 3 signals", func(t *testing.T) {
			cmd := run("presocket", "test-socket-ignore-context")
			command := exec.Command(cmd.Command[0], cmd.Command[1:]...)
			command.SysProcAttr = &syscall.SysProcAttr{
				Setpgid: true,
			}

			go func() {
				<-time.After(time.Second)
				// we're signalling the parent process directly and not
				// the process group, since we're testing the case where
				// the process is detached and not simulating a CTRL-C
				// from a TTY
				err := syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal CLI process")
				// TODO: same as above TODO, CLI signal handling is not consistent
				// with multiple signals without intervals
				time.Sleep(50 * time.Millisecond)
				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal CLI process")
				time.Sleep(50 * time.Millisecond)
				err = syscall.Kill(command.Process.Pid, syscall.SIGINT)
				assert.NilError(t, err, "failed to signal CLI process§")
			}()
			out, err := command.CombinedOutput()

			var exitError *exec.ExitError
			assert.Assert(t, errors.As(err, &exitError))
			assert.Check(t, exitError.Exited())
			assert.Check(t, is.Equal(exitError.ExitCode(), 1))

			// the plugin process does not receive a SIGINT and does
			// not exit after having it's context canceled, so the CLI
			// kills the plugin process and forcefully exits
			assert.Equal(t, string(out), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
		})
	})
}
