//go:build !windows

package commandconn

import (
	"context"
	"io"
	"io/fs"
	"testing"
	"time"

	"github.com/docker/docker/pkg/process"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
)

// For https://github.com/docker/cli/pull/1014#issuecomment-409308139
func TestEOFWithError(t *testing.T) {
	ctx := context.TODO()
	cmd := "sh"
	args := []string{"-c", "echo hello; echo some error >&2; exit 42"}
	c, err := New(ctx, cmd, args...)
	assert.NilError(t, err)
	b := make([]byte, 32)
	n, err := c.Read(b)
	assert.Check(t, is.Equal(len("hello\n"), n))
	assert.NilError(t, err)
	n, err = c.Read(b)
	assert.Check(t, is.Equal(0, n))
	assert.ErrorContains(t, err, "some error")
	assert.ErrorContains(t, err, "42")
}

func TestEOFWithoutError(t *testing.T) {
	ctx := context.TODO()
	cmd := "sh"
	args := []string{"-c", "echo hello; echo some debug log >&2; exit 0"}
	c, err := New(ctx, cmd, args...)
	assert.NilError(t, err)
	b := make([]byte, 32)
	n, err := c.Read(b)
	assert.Check(t, is.Equal(len("hello\n"), n))
	assert.NilError(t, err)
	n, err = c.Read(b)
	assert.Check(t, is.Equal(0, n))
	assert.Check(t, is.Equal(io.EOF, err))
}

func TestCloseRunningCommand(t *testing.T) {
	cmd := "sh"
	args := []string{"-c", "while true; sleep 1; done"}

	done := make(chan struct{})
	defer close(done)

	go func() {
		c, err := New(context.TODO(), cmd, args...)
		assert.NilError(t, err)
		cmdConn := c.(*commandConn)
		assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

		n, err := c.Write([]byte("hello"))
		assert.Check(t, is.Equal(len("hello"), n))
		assert.NilError(t, err)
		assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

		err = cmdConn.Close()
		assert.NilError(t, err)
		assert.Check(t, !process.Alive(cmdConn.cmd.Process.Pid))
		done <- struct{}{}
	}()

	select {
	case <-time.After(5 * time.Second):
		t.Error("test did not finish in time")
	case <-done:
		break
	}
}

func TestCloseTwice(t *testing.T) {
	cmd := "sh"
	args := []string{"-c", "echo hello; sleep 1; exit 0"}

	done := make(chan struct{})
	go func() {
		c, err := New(context.TODO(), cmd, args...)
		assert.NilError(t, err)
		cmdConn := c.(*commandConn)
		assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

		b := make([]byte, 32)
		n, err := c.Read(b)
		assert.Check(t, is.Equal(len("hello\n"), n))
		assert.NilError(t, err)

		err = cmdConn.Close()
		assert.NilError(t, err)
		assert.Check(t, !process.Alive(cmdConn.cmd.Process.Pid))

		err = cmdConn.Close()
		assert.NilError(t, err)
		assert.Check(t, !process.Alive(cmdConn.cmd.Process.Pid))
		done <- struct{}{}
	}()

	select {
	case <-time.After(10 * time.Second):
		t.Error("test did not finish in time")
	case <-done:
		break
	}
}

func TestEOFTimeout(t *testing.T) {
	cmd := "sh"
	args := []string{"-c", "sleep 20"}

	done := make(chan struct{})
	go func() {
		c, err := New(context.TODO(), cmd, args...)
		assert.NilError(t, err)
		cmdConn := c.(*commandConn)
		assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

		cmdConn.stdout = mockStdoutEOF{}

		b := make([]byte, 32)
		n, err := c.Read(b)
		assert.Check(t, is.Equal(0, n))
		assert.ErrorContains(t, err, "did not exit after EOF")

		done <- struct{}{}
	}()

	// after receiving an EOF, we try to kill the command
	// if it doesn't exit after 10s, we throw an error
	select {
	case <-time.After(12 * time.Second):
		t.Error("test did not finish in time")
	case <-done:
		break
	}
}

type mockStdoutEOF struct{}

func (mockStdoutEOF) Read(_ []byte) (int, error) {
	return 0, io.EOF
}

func (mockStdoutEOF) Close() error {
	return nil
}

func TestCloseWhileWriting(t *testing.T) {
	cmd := "sh"
	args := []string{"-c", "while true; sleep 1; done"}

	c, err := New(context.TODO(), cmd, args...)
	assert.NilError(t, err)
	cmdConn := c.(*commandConn)
	assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

	writeErrC := make(chan error)
	go func() {
		for {
			n, err := c.Write([]byte("hello"))
			if err != nil {
				writeErrC <- err
				return
			}
			assert.Equal(t, n, len("hello"))
		}
	}()

	err = c.Close()
	assert.NilError(t, err)
	assert.Check(t, !process.Alive(cmdConn.cmd.Process.Pid))

	writeErr := <-writeErrC
	assert.ErrorContains(t, writeErr, "file already closed")
	assert.Check(t, is.ErrorIs(writeErr, fs.ErrClosed))
}

func TestCloseWhileReading(t *testing.T) {
	cmd := "sh"
	args := []string{"-c", "while true; sleep 1; done"}

	c, err := New(context.TODO(), cmd, args...)
	assert.NilError(t, err)
	cmdConn := c.(*commandConn)
	assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))

	readErrC := make(chan error)
	go func() {
		for {
			b := make([]byte, 32)
			n, err := c.Read(b)
			if err != nil {
				readErrC <- err
				return
			}
			assert.Check(t, is.Equal(0, n))
		}
	}()

	err = cmdConn.Close()
	assert.NilError(t, err)
	assert.Check(t, !process.Alive(cmdConn.cmd.Process.Pid))

	readErr := <-readErrC
	assert.Check(t, is.ErrorIs(readErr, fs.ErrClosed))
}
