// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package plugin

import (
	"archive/tar"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"testing"
	"time"

	"code.forgejo.org/forgejo/runner/v13/act/container"
	pluginv1alpha "code.forgejo.org/forgejo/runner/v13/act/plugin/proto/v1alpha"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/health"
	"google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/test/bufconn"
	"google.golang.org/protobuf/proto"
)

const bufSize = 1024 * 1024

type mockPluginServer struct {
	pluginv1alpha.UnimplementedBackendPluginServer

	createReq    *pluginv1alpha.CreateRequest
	removeCalled bool

	startStdout               string
	startStderr               string
	startMissingStartComplete bool
	startStreamError          bool

	execReq      *pluginv1alpha.ExecRequest
	execExitCode int32
	execError    string
	execStdout   string
	execStderr   string

	copyInData    []byte
	copyInDest    string
	copyOutData   []byte
	startImageEnv map[string]string
}

func (s *mockPluginServer) Capabilities(_ context.Context, _ *pluginv1alpha.CapabilitiesRequest) (*pluginv1alpha.CapabilitiesResponse, error) {
	return &pluginv1alpha.CapabilitiesResponse{
		Name: "test-backend",
	}, nil
}

func (s *mockPluginServer) Create(_ context.Context, req *pluginv1alpha.CreateRequest) (*pluginv1alpha.CreateResponse, error) {
	s.createReq = req
	return &pluginv1alpha.CreateResponse{
		EnvironmentId:       "test-env-123",
		RootPath:            "/test/root",
		ActPath:             "/test/root/act",
		ToolCachePath:       "/test/root/toolcache",
		TempPath:            "/tmp",
		PathVariableName:    proto.String("PATH"),
		DefaultPathVariable: proto.String("/usr/bin:/bin"),
		PathSeparator:       proto.String(":"),
		Os:                  "Linux",
		Arch:                "x86_64",
	}, nil
}

func (s *mockPluginServer) Start(_ *pluginv1alpha.StartRequest, stream grpc.ServerStreamingServer[pluginv1alpha.StartOutput]) error {
	if s.startStreamError {
		return errors.New("startStreamError")
	}
	if s.startStdout != "" {
		_ = stream.Send(&pluginv1alpha.StartOutput{
			Output: &pluginv1alpha.StartOutput_Data{
				Data: &pluginv1alpha.DataChunk{
					Stream: pluginv1alpha.DataChunk_STDOUT,
					Data:   []byte(s.startStdout),
				},
			},
		})
	}
	if s.startStderr != "" {
		_ = stream.Send(&pluginv1alpha.StartOutput{
			Output: &pluginv1alpha.StartOutput_Data{
				Data: &pluginv1alpha.DataChunk{
					Stream: pluginv1alpha.DataChunk_STDERR,
					Data:   []byte(s.startStderr),
				},
			},
		})
	}
	if !s.startMissingStartComplete {
		_ = stream.Send(&pluginv1alpha.StartOutput{
			Output: &pluginv1alpha.StartOutput_StartComplete{
				StartComplete: &pluginv1alpha.StartComplete{ImageEnv: s.startImageEnv},
			},
		})
	}
	return nil
}

func (s *mockPluginServer) Exec(req *pluginv1alpha.ExecRequest, stream grpc.ServerStreamingServer[pluginv1alpha.ExecOutput]) error {
	s.execReq = req
	if s.execStdout != "" {
		_ = stream.Send(&pluginv1alpha.ExecOutput{
			Output: &pluginv1alpha.ExecOutput_Data{
				Data: &pluginv1alpha.DataChunk{
					Stream: pluginv1alpha.DataChunk_STDOUT,
					Data:   []byte(s.execStdout),
				},
			},
		})
	}
	if s.execStderr != "" {
		_ = stream.Send(&pluginv1alpha.ExecOutput{
			Output: &pluginv1alpha.ExecOutput_Data{
				Data: &pluginv1alpha.DataChunk{
					Stream: pluginv1alpha.DataChunk_STDERR,
					Data:   []byte(s.execStderr),
				},
			},
		})
	}
	if s.execError != "" {
		_ = stream.Send(&pluginv1alpha.ExecOutput{
			Output: &pluginv1alpha.ExecOutput_ExecFailed{
				ExecFailed: &pluginv1alpha.ExecFailed{
					ErrorMessage: s.execError,
				},
			},
		})
	} else {
		_ = stream.Send(&pluginv1alpha.ExecOutput{
			Output: &pluginv1alpha.ExecOutput_ExecComplete{
				ExecComplete: &pluginv1alpha.ExecComplete{
					ExitCode: s.execExitCode,
				},
			},
		})
	}
	return nil
}

func (s *mockPluginServer) CopyIn(stream grpc.ClientStreamingServer[pluginv1alpha.CopyInChunk, pluginv1alpha.CopyInResponse]) error {
	var buf bytes.Buffer
	for {
		chunk, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}
		if s.copyInDest == "" {
			s.copyInDest = chunk.GetDestPath()
		}
		buf.Write(chunk.GetData())
	}
	s.copyInData = buf.Bytes()
	return stream.SendAndClose(&pluginv1alpha.CopyInResponse{})
}

func (s *mockPluginServer) CopyOut(_ *pluginv1alpha.CopyOutRequest, stream grpc.ServerStreamingServer[pluginv1alpha.CopyOutChunk]) error {
	if s.copyOutData != nil {
		_ = stream.Send(&pluginv1alpha.CopyOutChunk{Data: s.copyOutData})
	}
	return nil
}

func (s *mockPluginServer) Remove(_ context.Context, _ *pluginv1alpha.RemoveRequest) (*pluginv1alpha.RemoveResponse, error) {
	s.removeCalled = true
	return &pluginv1alpha.RemoveResponse{}, nil
}

func startMockServer(t *testing.T) (*mockPluginServer, *grpc.ClientConn) {
	t.Helper()

	lis := bufconn.Listen(bufSize)
	srv := grpc.NewServer()
	mock := &mockPluginServer{
		execStdout: "hello\n",
	}
	pluginv1alpha.RegisterBackendPluginServer(srv, mock)

	healthSrv := health.NewServer()
	grpc_health_v1.RegisterHealthServer(srv, healthSrv)
	healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)

	go func() {
		_ = srv.Serve(lis)
	}()
	t.Cleanup(srv.Stop)

	conn, err := grpc.NewClient(
		"passthrough:///bufconn",
		grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
			return lis.DialContext(ctx)
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	require.NoError(t, err)
	t.Cleanup(func() { conn.Close() })

	return mock, conn
}

func newTestEnv(t *testing.T, conn *grpc.ClientConn) *pluginEnvironment {
	t.Helper()
	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
	require.NoError(t, err)
	return &pluginEnvironment{
		client: rpc,
		caps:   caps,
		input: &container.NewContainerInput{
			Image:           "test:latest",
			Name:            "test-container",
			Env:             []string{"FOO=bar"},
			WorkingDir:      "/workspace",
			DefaultPlatform: "linux/amd64",
		},
		backendOpts: map[string]string{"ns": "default"},
		labelArg:    "ubuntu-24.04",
		timeout:     15 * time.Minute,
		stdout:      io.Discard,
		stderr:      io.Discard,
	}
}

func TestPluginEnvironment_Capabilities(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	assert.Equal(t, "test-backend", env.BackendID())
	assert.Equal(t, "test-backend", env.GetName())
	assert.False(t, env.SupportsDockerContainerActions())
	assert.True(t, env.ManagesOwnNetworking())
	assert.Equal(t, "/some/path", env.ToContainerPath("/some/path"))
}

func TestPluginEnvironment_StateAccessBeforeStart(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	assert.Panics(t, func() { env.GetRoot() })
	assert.Panics(t, func() { env.GetActPath() })
	assert.Panics(t, func() { env.GetPathVariableName() })
	assert.Panics(t, func() { env.DefaultPathVariable() })
	assert.Panics(t, func() { env.JoinPathVariable("/a", "/b") })
	assert.Panics(t, func() { env.IsEnvironmentCaseInsensitive() })
	assert.Panics(t, func() { env.GetRunnerContext(t.Context()) })
}

func TestPluginEnvironment_StateDefaults(t *testing.T) {
	_, conn := startMockServer(t)
	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	env := &pluginEnvironment{
		client:     rpc,
		caps:       &pluginv1alpha.CapabilitiesResponse{},
		envCreated: &createdEnvironment{},
		input:      &container.NewContainerInput{},
		stdout:     io.Discard,
		stderr:     io.Discard,
	}

	assert.Equal(t, "PATH", env.GetPathVariableName())
	assert.Equal(t, "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", env.DefaultPathVariable())
	assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
}

func TestPluginEnvironment_StateFromStart(t *testing.T) {
	_, conn := startMockServer(t)
	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	env := &pluginEnvironment{
		client: rpc,
		caps:   &pluginv1alpha.CapabilitiesResponse{},
		envCreated: &createdEnvironment{
			envID:                        "abc",
			rootPath:                     "/my-root",
			actPath:                      "/my-act-path",
			toolCachePath:                "/my-tool-cache-path",
			tempPath:                     "/my-tmp-path",
			pathVariableName:             "MyPath",
			defaultPathVariable:          "C:\\Tmp",
			pathSeparator:                ";",
			environmentOS:                "plan9",
			environmentArch:              "riscv5",
			isEnvironmentCaseInsensitive: true,
		},
		input:  &container.NewContainerInput{},
		stdout: io.Discard,
		stderr: io.Discard,
	}

	assert.Equal(t, "/my-root", env.GetRoot())
	assert.Equal(t, "/my-act-path", env.GetActPath())
	assert.Equal(t, "MyPath", env.GetPathVariableName())
	assert.Equal(t, "C:\\Tmp", env.DefaultPathVariable())
	assert.Equal(t, "/a;/b", env.JoinPathVariable("/a", "/b"))
	assert.Equal(t, true, env.IsEnvironmentCaseInsensitive())
	assert.Equal(t, map[string]any{
		"arch":       "riscv5",
		"os":         "plan9",
		"temp":       "/my-tmp-path",
		"tool_cache": "/my-tool-cache-path",
	}, env.GetRunnerContext(t.Context()))
}

func TestPluginEnvironment_CreatePassesInput(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	env.AddServiceContainerRaw("redis", "redis:7", map[string]string{"REDIS_PASS": "secret"}, []string{"6379"})

	err := env.Create([]string{"NET_ADMIN"}, []string{"MKNOD"})(t.Context())
	require.NoError(t, err)
	require.NotNil(t, env.envCreated)
	assert.Equal(t, "test-env-123", env.envCreated.envID)
	assert.Equal(t, "/test/root", env.GetRoot())
	assert.Equal(t, "/test/root/act", env.GetActPath())
	rc := env.GetRunnerContext(t.Context())
	assert.Equal(t, "Linux", rc["os"])
	assert.Equal(t, "x86_64", rc["arch"])
	assert.Equal(t, "/tmp", rc["temp"])
	assert.Equal(t, "/test/root/toolcache", rc["tool_cache"])

	req := mock.createReq
	require.NotNil(t, req)
	assert.Equal(t, "test:latest", req.Image)
	assert.Equal(t, "test-container", req.Name)
	assert.Equal(t, []string{"NET_ADMIN"}, req.CapAdd)
	assert.Equal(t, []string{"MKNOD"}, req.CapDrop)
	assert.Equal(t, "default", req.BackendOptions["ns"])
	assert.Equal(t, "ubuntu-24.04", req.LabelArg)
	assert.Equal(t, 15*time.Minute, req.EnvironmentTimeout.AsDuration())

	require.Len(t, req.Services, 1)
	assert.Equal(t, "redis", req.Services[0].Name)
	assert.Equal(t, "redis:7", req.Services[0].Image)
	assert.Equal(t, "secret", req.Services[0].Env["REDIS_PASS"])
	assert.Equal(t, []string{"6379"}, req.Services[0].Ports)

	assert.Contains(t, env.envCreated.envVariables, "RUNNER_TOOL_CACHE=/test/root/toolcache")
	assert.Contains(t, env.envCreated.envVariables, "RUNNER_TEMP=/tmp")
}

func TestPluginEnvironment_Lifecycle(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Start(false)(t.Context()))

	var stdout bytes.Buffer
	env.ReplaceLogWriter(&stdout, io.Discard)

	require.NoError(t, env.Exec([]string{"echo", "hello"}, nil, "", "")(t.Context()))
	assert.Equal(t, "hello\n", stdout.String())

	wait, err := env.IsHealthy(t.Context())
	require.NoError(t, err)
	assert.Equal(t, time.Duration(0), wait)

	require.NoError(t, env.Remove()(t.Context()))
	assert.True(t, mock.removeCalled)
}

func TestPluginEnvironment_StartMixedOutput(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.startStdout = "out-line\n"
	mock.startStderr = "err-line\n"

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	var stdout, stderr bytes.Buffer
	env.ReplaceLogWriter(&stdout, &stderr)

	err := env.Start(false)(t.Context())
	require.NoError(t, err)
	assert.Equal(t, "out-line\n", stdout.String())
	assert.Equal(t, "err-line\n", stderr.String())
}

func TestPluginEnvironment_StartMissingStartComplete(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.startMissingStartComplete = true
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Start(false)(t.Context())
	require.ErrorContains(t, err, "stream ended before completion signal")
}

func TestPluginEnvironment_StartStreamError(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.startStreamError = true
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Start(false)(t.Context())
	require.ErrorContains(t, err, "stream error rpc error: code = Unknown desc = startStreamError")
}

func TestPluginEnvironment_ExecRequest_Defaults(t *testing.T) {
	mock, conn := startMockServer(t)

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Exec([]string{"cmd"}, nil, "", "")(t.Context()))

	// Workdir & Env should contain the defaults from the create request (from newTestEnv)
	require.NotNil(t, mock.execReq.Workdir)
	assert.Equal(t, "/workspace", mock.execReq.Workdir)
	foo, ok := mock.execReq.Env["FOO"]
	assert.True(t, ok)
	assert.Equal(t, "bar", foo)
}

func TestPluginEnvironment_ExecRequest_Overrides(t *testing.T) {
	mock, conn := startMockServer(t)

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Exec([]string{"cmd"}, map[string]string{"FOO": "not bar"}, "", "/new-workdir")(t.Context()))

	// Workdir & Env should contain the overrides from the Exec cmd
	require.NotNil(t, mock.execReq.Workdir)
	assert.Equal(t, "/new-workdir", mock.execReq.Workdir)
	foo, ok := mock.execReq.Env["FOO"]
	assert.True(t, ok)
	assert.Equal(t, "not bar", foo)
}

func TestPluginEnvironment_ExecStderr(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.execStdout = ""
	mock.execStderr = "warning: something\n"

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	var stderr bytes.Buffer
	env.ReplaceLogWriter(io.Discard, &stderr)

	require.NoError(t, env.Exec([]string{"cmd"}, nil, "", "")(t.Context()))
	assert.Equal(t, "warning: something\n", stderr.String())
}

func TestPluginEnvironment_ExecNonZeroExit(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.execStdout = ""
	mock.execExitCode = 1
	mock.execError = ""

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Exec([]string{"false"}, nil, "", "")(t.Context())
	require.Error(t, err)
	assert.Contains(t, err.Error(), "exit code 1")

	var execErr *ExecError
	require.ErrorAs(t, err, &execErr)
	assert.Equal(t, int32(1), execErr.ExitCode)
	assert.Equal(t, "", execErr.Message)
}

func TestPluginEnvironment_ExecErrorMessage(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.execStdout = ""
	mock.execError = "exec: command not found"

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Exec([]string{"nonexistent"}, nil, "", "")(t.Context())
	require.Error(t, err)
	assert.Contains(t, err.Error(), "command not found")

	var execErr *ExecError
	require.ErrorAs(t, err, &execErr)
	assert.Equal(t, "exec: command not found", execErr.Message)
}

func TestPluginEnvironment_Copy(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Copy("/dest", &container.FileEntry{
		Name: "hello.txt",
		Mode: 0o644,
		Body: "hello world",
	})(t.Context())
	require.NoError(t, err)

	assert.Equal(t, "/dest", mock.copyInDest)
	require.NotEmpty(t, mock.copyInData)

	tr := tar.NewReader(bytes.NewReader(mock.copyInData))
	hdr, err := tr.Next()
	require.NoError(t, err)
	assert.Equal(t, "hello.txt", hdr.Name)
	assert.Equal(t, int64(0o644), hdr.Mode)

	body, err := io.ReadAll(tr)
	require.NoError(t, err)
	assert.Equal(t, "hello world", string(body))
}

func TestPluginEnvironment_CopyTarStream(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	var buf bytes.Buffer
	tw := tar.NewWriter(&buf)
	_ = tw.WriteHeader(&tar.Header{Name: "file.txt", Size: 4, Mode: 0o644})
	_, _ = tw.Write([]byte("data"))
	_ = tw.Close()

	err := env.CopyTarStream(t.Context(), "/tar-dest", &buf)
	require.NoError(t, err)
	assert.Equal(t, "/tar-dest", mock.copyInDest)
}

func TestPluginEnvironment_GetContainerArchive(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.copyOutData = []byte("tar-archive-bytes")

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	rc, err := env.GetContainerArchive(t.Context(), "/src/file.txt")
	require.NoError(t, err)
	defer rc.Close()

	data, err := io.ReadAll(rc)
	require.NoError(t, err)
	assert.Equal(t, "tar-archive-bytes", string(data))
}

func TestPluginEnvironment_UpdateFromEnv(t *testing.T) {
	var tarBuf bytes.Buffer
	tw := tar.NewWriter(&tarBuf)
	content := []byte("FOO=bar\nNEW=value\n")
	require.NoError(t, tw.WriteHeader(&tar.Header{Name: "env", Mode: 0o644, Size: int64(len(content))}))
	_, _ = tw.Write(content)
	require.NoError(t, tw.Close())

	mock, conn := startMockServer(t)
	mock.copyOutData = tarBuf.Bytes()

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	envMap := map[string]string{"FOO": "old"}
	err := env.UpdateFromEnv("/path/to/env", &envMap)(t.Context())
	require.NoError(t, err)
	assert.Equal(t, "bar", envMap["FOO"])
	assert.Equal(t, "value", envMap["NEW"])
}

func TestPluginEnvironment_ReplaceLogWriter(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	var w1, w2 bytes.Buffer
	env.stdout = &w1
	env.stderr = &w2

	var w3, w4 bytes.Buffer
	oldOut, oldErr := env.ReplaceLogWriter(&w3, &w4)
	assert.Equal(t, &w1, oldOut)
	assert.Equal(t, &w2, oldErr)

	env.mu.Lock()
	assert.Equal(t, &w3, env.stdout)
	assert.Equal(t, &w4, env.stderr)
	env.mu.Unlock()
}

func TestPluginEnvironment_NoOpMethods(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	require.NoError(t, env.Pull(false)(t.Context()))
	require.NoError(t, env.ConnectToNetwork("net")(t.Context()))
	require.NoError(t, env.Close()(t.Context()))

	envMap := map[string]string{}
	require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))
}

func TestClient_HealthCheckAndCapabilities(t *testing.T) {
	_, conn := startMockServer(t)

	healthClient := grpc_health_v1.NewHealthClient(conn)
	resp, err := healthClient.Check(t.Context(), &grpc_health_v1.HealthCheckRequest{})
	require.NoError(t, err)
	assert.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, resp.GetStatus())

	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
	require.NoError(t, err)
	assert.Equal(t, "test-backend", caps.Name)
}

func TestClient_NewEnvironment(t *testing.T) {
	_, conn := startMockServer(t)
	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
	require.NoError(t, err)

	c := &Client{conn: conn, rpc: rpc, caps: caps}
	input := &container.NewContainerInput{
		Image:  "img:latest",
		Stdout: io.Discard,
		Stderr: io.Discard,
	}
	env := c.NewEnvironment(input, map[string]string{"key": "val"}, "ubuntu-24.04", 15*time.Minute)
	assert.Equal(t, "test-backend", env.BackendID())
}

func TestPluginEnvironment_ExecMixedOutput(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.execStdout = "out-line\n"
	mock.execStderr = "err-line\n"

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	var stdout, stderr bytes.Buffer
	env.ReplaceLogWriter(&stdout, &stderr)

	err := env.Exec([]string{"cmd"}, map[string]string{"K": "V"}, "user", "/work")(t.Context())
	require.NoError(t, err)
	assert.Equal(t, "out-line\n", stdout.String())
	assert.Equal(t, "err-line\n", stderr.String())
}

func TestPluginEnvironment_CopyMultipleFiles(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.Copy(
		"/dest",
		&container.FileEntry{Name: "a.txt", Mode: 0o644, Body: "aaa"},
		&container.FileEntry{Name: "b.txt", Mode: 0o755, Body: "bbb"},
	)(t.Context())
	require.NoError(t, err)

	tr := tar.NewReader(bytes.NewReader(mock.copyInData))
	names := []string{}
	for {
		hdr, err := tr.Next()
		if err != nil {
			break
		}
		names = append(names, hdr.Name)
		body, _ := io.ReadAll(tr)
		switch hdr.Name {
		case "a.txt":
			assert.Equal(t, "aaa", string(body))
		case "b.txt":
			assert.Equal(t, "bbb", string(body))
		}
	}
	assert.Equal(t, []string{"a.txt", "b.txt"}, names)
}

func TestPluginEnvironment_ServiceAdder(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)

	env.AddServiceContainerRaw("db", "postgres:16", map[string]string{"POSTGRES_PASSWORD": "pw"}, []string{"5432"})
	env.AddServiceContainerRaw("cache", "redis:7", nil, []string{"6379"})

	require.NoError(t, env.Create(nil, nil)(t.Context()))

	req := mock.createReq
	require.Len(t, req.Services, 2)
	assert.Equal(t, "db", req.Services[0].Name)
	assert.Equal(t, "postgres:16", req.Services[0].Image)
	assert.Equal(t, "pw", req.Services[0].Env["POSTGRES_PASSWORD"])
	assert.Equal(t, "cache", req.Services[1].Name)
}

func TestPluginEnvironment_ExecContextCancelled(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	ctx, cancel := context.WithCancel(t.Context())
	cancel()

	err := env.Exec([]string{"sleep", "10"}, nil, "", "")(ctx)
	require.Error(t, err)
	assert.Contains(t, err.Error(), fmt.Sprintf("%v", context.Canceled))
}

func TestPluginEnvironment_UpdateFromImageEnv(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.startImageEnv = map[string]string{
		"PATH":   "/custom/bin:/usr/bin",
		"GOPATH": "/go",
		"LANG":   "C.UTF-8",
	}

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Start(false)(t.Context()))

	envMap := map[string]string{"LANG": "en_US.UTF-8"}
	require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))

	assert.Equal(t, "/custom/bin:/usr/bin", envMap["PATH"])
	assert.Equal(t, "/go", envMap["GOPATH"])
	assert.Equal(t, "en_US.UTF-8", envMap["LANG"])
}

func TestPluginEnvironment_UpdateFromImageEnv_MergesPath(t *testing.T) {
	mock, conn := startMockServer(t)
	mock.startImageEnv = map[string]string{
		"PATH": "/image/bin",
	}

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Start(false)(t.Context()))

	envMap := map[string]string{"PATH": "/existing/bin"}
	require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))

	assert.Equal(t, "/existing/bin:/image/bin", envMap["PATH"])
}

func TestPluginEnvironment_UpdateFromImageEnv_NilImageEnv(t *testing.T) {
	_, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))
	require.NoError(t, env.Start(false)(t.Context()))

	envMap := map[string]string{"FOO": "bar"}
	require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))

	assert.Equal(t, map[string]string{"FOO": "bar"}, envMap)
}

// truncatingExecServer sends output but never Done, simulating a plugin crash mid-exec.
type truncatingExecServer struct {
	pluginv1alpha.UnimplementedBackendPluginServer
}

func (truncatingExecServer) Capabilities(_ context.Context, _ *pluginv1alpha.CapabilitiesRequest) (*pluginv1alpha.CapabilitiesResponse, error) {
	return &pluginv1alpha.CapabilitiesResponse{Name: "trunc"}, nil
}

func (truncatingExecServer) Create(_ context.Context, _ *pluginv1alpha.CreateRequest) (*pluginv1alpha.CreateResponse, error) {
	return &pluginv1alpha.CreateResponse{EnvironmentId: "trunc", RootPath: "/r", ActPath: "/r/act"}, nil
}

func (truncatingExecServer) Exec(_ *pluginv1alpha.ExecRequest, stream grpc.ServerStreamingServer[pluginv1alpha.ExecOutput]) error {
	_ = stream.Send(&pluginv1alpha.ExecOutput{
		Output: &pluginv1alpha.ExecOutput_Data{
			Data: &pluginv1alpha.DataChunk{
				Stream: pluginv1alpha.DataChunk_STDOUT,
				Data:   []byte("partial"),
			},
		},
	})
	return nil
}

func TestPluginEnvironment_ExecStreamTruncated(t *testing.T) {
	lis := bufconn.Listen(bufSize)
	srv := grpc.NewServer()
	pluginv1alpha.RegisterBackendPluginServer(srv, truncatingExecServer{})
	go func() { _ = srv.Serve(lis) }()
	t.Cleanup(srv.Stop)

	conn, err := grpc.NewClient(
		"passthrough:///bufconn",
		grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
			return lis.DialContext(ctx)
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	require.NoError(t, err)
	t.Cleanup(func() { conn.Close() })

	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err = env.Exec([]string{"cmd"}, nil, "", "")(t.Context())
	require.Error(t, err)
	assert.Contains(t, err.Error(), "stream ended before completion signal")
}

func TestPluginEnvironment_CopyEmpty(t *testing.T) {
	mock, conn := startMockServer(t)
	env := newTestEnv(t, conn)
	require.NoError(t, env.Create(nil, nil)(t.Context()))

	err := env.CopyTarStream(t.Context(), "/empty-dest", bytes.NewReader(nil))
	require.NoError(t, err)
	assert.Equal(t, "/empty-dest", mock.copyInDest)
}
