package runtime

import (
	"bytes"
	"context"
	"fmt"
	"net"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	dockerimage "github.com/docker/docker/api/types/image"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/docker/api/types/network"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/pkg/stdcopy"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/AgentTeam/internal/automation/dsl"
)

func TestDockerExecutorRunsStepsWithContainerDefaultsAndLogs(t *testing.T) {
	now := time.Date(2026, 6, 13, 1, 0, 0, 0, time.UTC)
	client := &fakeDockerClient{
		attachOutputs: []fakeDockerAttachOutput{
			{stdout: "hello stdout\n"},
			{stderr: "hello stderr\n"},
		},
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{
		Images:      map[string]string{"ubuntu-latest": "ubuntu:24.04"},
		NetworkMode: "none",
		CPUs:        2,
		MemoryBytes: 2048,
		PidsLimit:   32,
		User:        "runner",
		Mounts:      []DockerBindMount{{Source: "/home/dev/tidb-wiki", Target: "/mnt/agentteam/tidb-wiki", ReadOnly: true}},
	})
	require.NoError(t, err)
	var stdout bytes.Buffer
	var stderr bytes.Buffer

	result, err := executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		RunnerID: "server-test",
		Job:      fakeDockerJobIdentity(),
		Spec: dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{
			{ID: "one", Run: "echo one"},
			{ID: "two", Name: "Two", Run: "echo two"},
		}},
		WorkDir: "/tmp/automation-job",
		Env:     []string{"CI=true", "TMPDIR=/tmp"},
		Stdout:  &stdout,
		Stderr:  &stderr,
		Now:     func() time.Time { return now },
	})

	require.NoError(t, err)
	require.Equal(t, "hello stdout\n", stdout.String())
	require.Equal(t, "hello stderr\n", stderr.String())
	require.Equal(t, []JobStepResult{
		{ID: "one", Index: 0, ExitCode: 0, StartedAt: now.Format(time.RFC3339Nano), CompletedAt: now.Format(time.RFC3339Nano)},
		{ID: "two", Name: "Two", Index: 1, ExitCode: 0, StartedAt: now.Format(time.RFC3339Nano), CompletedAt: now.Format(time.RFC3339Nano)},
	}, result.Steps)

	client.mu.Lock()
	defer client.mu.Unlock()
	require.True(t, client.started)
	require.True(t, client.removed)
	require.False(t, client.killed)
	require.Equal(t, "ubuntu:24.04", client.createConfig.Image)
	require.Equal(t, "runner", client.createConfig.User)
	require.Equal(t, dockerWorkspaceDir, client.createConfig.WorkingDir)
	require.Equal(t, []string{"CI=true", "TMPDIR=/tmp"}, client.createConfig.Env)
	require.True(t, client.createConfig.NetworkDisabled)
	require.Equal(t, container.NetworkMode("none"), client.hostConfig.NetworkMode)
	require.False(t, client.hostConfig.Privileged)
	require.Equal(t, []string{"ALL"}, []string(client.hostConfig.CapDrop))
	require.Contains(t, client.hostConfig.SecurityOpt, "no-new-privileges")
	require.Equal(t, int64(2_000_000_000), client.hostConfig.NanoCPUs)
	require.Equal(t, int64(2048), client.hostConfig.Memory)
	require.NotNil(t, client.hostConfig.PidsLimit)
	require.Equal(t, int64(32), *client.hostConfig.PidsLimit)
	require.Len(t, client.hostConfig.Mounts, 2)
	require.Equal(t, mount.TypeBind, client.hostConfig.Mounts[0].Type)
	require.Equal(t, "/tmp/automation-job", client.hostConfig.Mounts[0].Source)
	require.Equal(t, dockerWorkspaceDir, client.hostConfig.Mounts[0].Target)
	require.False(t, client.hostConfig.Mounts[0].ReadOnly)
	require.Equal(t, mount.TypeBind, client.hostConfig.Mounts[1].Type)
	require.Equal(t, "/home/dev/tidb-wiki", client.hostConfig.Mounts[1].Source)
	require.Equal(t, "/mnt/agentteam/tidb-wiki", client.hostConfig.Mounts[1].Target)
	require.True(t, client.hostConfig.Mounts[1].ReadOnly)
	require.Equal(t, []string{"/bin/sh", "-e", "-c", "echo one"}, client.execOptions[0].Cmd)
	require.Equal(t, []string{"/bin/sh", "-e", "-c", "echo two"}, client.execOptions[1].Cmd)
	require.Equal(t, []string{"CI=true", "TMPDIR=/tmp"}, client.execOptions[0].Env)
	require.Equal(t, []string{"CI=true", "TMPDIR=/tmp"}, client.execOptions[1].Env)
}

func TestDockerExecutorUsesStepEnvForEachExec(t *testing.T) {
	client := &fakeDockerClient{attachOutputs: []fakeDockerAttachOutput{{stdout: "one\n"}, {stdout: "two\n"}}}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{Run: "echo one"}, {Run: "echo two"}}},
		WorkDir: "/tmp/automation-job",
		Env:     []string{"CI=true"},
		StepEnv: [][]string{
			{"CI=true", "TOKEN=one"},
			{"CI=true", "TOKEN=two", "STEP=second"},
		},
		Now: time.Now,
	})

	require.NoError(t, err)
	client.mu.Lock()
	defer client.mu.Unlock()
	require.Equal(t, []string{"CI=true"}, client.createConfig.Env)
	require.Equal(t, []string{"CI=true", "TOKEN=one"}, client.execOptions[0].Env)
	require.Equal(t, []string{"CI=true", "TOKEN=two", "STEP=second"}, client.execOptions[1].Env)
}

func TestDockerExecutorDelegatesUsesStepsInOrder(t *testing.T) {
	now := time.Date(2026, 6, 13, 1, 5, 0, 0, time.UTC)
	client := &fakeDockerClient{attachOutputs: []fakeDockerAttachOutput{{stdout: "after checkout\n"}}}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	var calls []string

	result, err := executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job: fakeDockerJobIdentity(),
		Spec: dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{
			{Uses: dsl.AgentTeamCheckoutUse},
			{Run: "test -f go.mod"},
		}},
		WorkDir: "/tmp/automation-job",
		Now:     func() time.Time { return now },
		UsesStepHandler: func(ctx context.Context, req UsesStepRequest) (int, error) {
			calls = append(calls, fmt.Sprintf("%d:%s", req.Index, req.Step.Uses))
			return 0, nil
		},
	})

	require.NoError(t, err)
	require.Equal(t, []string{"0:agentteam/checkout"}, calls)
	require.Equal(t, []JobStepResult{
		{Index: 0, ExitCode: 0, StartedAt: now.Format(time.RFC3339Nano), CompletedAt: now.Format(time.RFC3339Nano)},
		{Index: 1, ExitCode: 0, StartedAt: now.Format(time.RFC3339Nano), CompletedAt: now.Format(time.RFC3339Nano)},
	}, result.Steps)
	client.mu.Lock()
	defer client.mu.Unlock()
	require.Len(t, client.execOptions, 1)
	require.Equal(t, []string{"/bin/sh", "-e", "-c", "test -f go.mod"}, client.execOptions[0].Cmd)
}

func TestDockerExecutorDefaultsUserToServerProcess(t *testing.T) {
	client := &fakeDockerClient{}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{Run: "echo ok"}}},
		WorkDir: "/tmp/automation-job",
		Now:     time.Now,
	})
	require.NoError(t, err)

	client.mu.Lock()
	defer client.mu.Unlock()
	require.Equal(t, defaultDockerUser(), client.createConfig.User)
	require.Len(t, client.execOptions, 1)
	require.Equal(t, defaultDockerUser(), client.execOptions[0].User)
}

func TestDockerExecutorFailsClosedForUnsupportedRunsOn(t *testing.T) {
	executor, err := newDockerExecutorWithClient(&fakeDockerClient{}, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Spec: dsl.JobSpec{RunsOn: []any{"ubuntu-latest", "node-22"}, Steps: []dsl.StepSpec{{Run: "echo hi"}}},
		Now:  time.Now,
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "exactly one runs-on label")

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Spec: dsl.JobSpec{RunsOn: "node-22", Steps: []dsl.StepSpec{{Run: "echo hi"}}},
		Now:  time.Now,
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "not configured")

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Spec: dsl.JobSpec{RunsOn: map[string]any{"group": "trusted", "labels": []any{"ubuntu-latest"}}, Steps: []dsl.StepSpec{{Run: "echo hi"}}},
		Now:  time.Now,
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "runner group")
}

func TestDockerExecutorClassifiesImageMissingAtExecution(t *testing.T) {
	imageMissingClient := &fakeDockerClient{createErr: fmt.Errorf("No such image: ubuntu:missing")}
	executor, err := newDockerExecutorWithClient(imageMissingClient, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:missing"}})
	require.NoError(t, err)
	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{Run: "echo ok"}}},
		WorkDir: "/tmp/automation-job",
		Now:     time.Now,
	})
	require.Error(t, err)
	require.Equal(t, ExecutorFailureImageMissing, ExecutorFailureKindFor(err))
}

func TestDockerExecutorReportsNonZeroExitAndRemovesContainer(t *testing.T) {
	client := &fakeDockerClient{
		attachOutputs: []fakeDockerAttachOutput{{stdout: "before failure\n"}},
		inspectExit:   []int{7},
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	var stdout bytes.Buffer

	result, err := executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "fail", Run: "exit 7"}}},
		WorkDir: "/tmp/automation-job",
		Stdout:  &stdout,
		Now:     time.Now,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "automation shell exited with code 7")
	require.Len(t, result.Steps, 1)
	require.Equal(t, 7, result.Steps[0].ExitCode)
	require.Equal(t, "before failure\n", stdout.String())
	require.True(t, client.removedValue())
}

func TestDockerExecutorReturnsCleanupErrorAfterSuccessfulSteps(t *testing.T) {
	client := &fakeDockerClient{
		attachOutputs: []fakeDockerAttachOutput{{stdout: "before cleanup\n"}},
		removeErr:     fmt.Errorf("remove denied"),
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	var stdout bytes.Buffer

	result, err := executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "ok", Run: "echo ok"}}},
		WorkDir: "/tmp/automation-job",
		Stdout:  &stdout,
		Now:     time.Now,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "remove automation Docker container")
	require.Equal(t, ExecutorFailureContainerCleanup, ExecutorFailureKindFor(err))
	cleanupErr := executorCleanupFailure(err)
	require.Error(t, cleanupErr)
	require.Contains(t, cleanupErr.Error(), "remove automation Docker container")
	require.Len(t, result.Steps, 1)
	require.Equal(t, 0, result.Steps[0].ExitCode)
	require.Equal(t, "before cleanup\n", stdout.String())
	require.True(t, client.removedValue())
}

func TestDockerExecutorPreservesPrimaryFailureKindWhenCleanupFails(t *testing.T) {
	client := &fakeDockerClient{
		startErr:  fmt.Errorf("daemon unavailable"),
		removeErr: fmt.Errorf("remove denied"),
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)

	_, err = executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{Run: "echo ok"}}},
		WorkDir: "/tmp/automation-job",
		Now:     time.Now,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "start automation Docker container")
	require.Contains(t, err.Error(), "remove automation Docker container")
	require.Equal(t, ExecutorFailureDockerUnavailable, ExecutorFailureKindFor(err))
	cleanupErr := executorCleanupFailure(err)
	require.Error(t, cleanupErr)
	require.Contains(t, cleanupErr.Error(), "remove automation Docker container")
}

func TestDockerExecutorKillsAndRemovesContainerOnTimeout(t *testing.T) {
	client := &fakeDockerClient{attachOutputs: []fakeDockerAttachOutput{{blockUntilContextDone: true}}}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond)
	defer cancel()

	_, err = executor.ExecuteJob(ctx, ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "slow", Run: "sleep 10"}}},
		WorkDir: "/tmp/automation-job",
		Now:     time.Now,
	})

	require.Error(t, err)
	require.ErrorIs(t, err, context.DeadlineExceeded)
	require.True(t, client.removedValue())
	require.Eventually(t, client.killedValue, time.Second, 10*time.Millisecond)
}

func TestDockerExecutorClosesAttachOnTimeout(t *testing.T) {
	client := &fakeDockerClient{attachOutputs: []fakeDockerAttachOutput{{blockUntilAttachClosed: true}}}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	done := make(chan error, 1)
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond)
	defer cancel()

	go func() {
		_, err := executor.ExecuteJob(ctx, ExecuteJobRequest{
			Job:     fakeDockerJobIdentity(),
			Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "slow", Run: "sleep 10"}}},
			WorkDir: "/tmp/automation-job",
			Now:     time.Now,
		})
		done <- err
	}()

	select {
	case err := <-done:
		require.Error(t, err)
		require.ErrorIs(t, err, context.DeadlineExceeded)
		require.True(t, client.removedValue())
		require.Eventually(t, client.killedValue, time.Second, 10*time.Millisecond)
	case <-time.After(time.Second):
		t.Fatal("ExecuteJob did not return after timed-out Docker attach")
	}
}

func TestDockerExecutorFailsWhenExecStillRunningAfterAttachEOF(t *testing.T) {
	client := &fakeDockerClient{
		attachOutputs:  []fakeDockerAttachOutput{{stdout: "partial output\n"}},
		inspectRunning: []bool{true},
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	var stdout bytes.Buffer

	result, err := executor.ExecuteJob(t.Context(), ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "slow", Run: "sleep 10"}}},
		WorkDir: "/tmp/automation-job",
		Stdout:  &stdout,
		Now:     time.Now,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "still running")
	require.Len(t, result.Steps, 1)
	require.Equal(t, -1, result.Steps[0].ExitCode)
	require.Equal(t, "partial output\n", stdout.String())
	require.True(t, client.removedValue())
}

func TestDockerExecutorReturnsCleanupErrorAfterTimeout(t *testing.T) {
	client := &fakeDockerClient{
		attachOutputs: []fakeDockerAttachOutput{{blockUntilContextDone: true}},
		removeErr:     fmt.Errorf("remove denied"),
	}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Millisecond)
	defer cancel()

	_, err = executor.ExecuteJob(ctx, ExecuteJobRequest{
		Job:     fakeDockerJobIdentity(),
		Spec:    dsl.JobSpec{RunsOn: "ubuntu-latest", Steps: []dsl.StepSpec{{ID: "slow", Run: "sleep 10"}}},
		WorkDir: "/tmp/automation-job",
		Now:     time.Now,
	})

	require.Error(t, err)
	require.ErrorIs(t, err, context.DeadlineExceeded)
	require.Contains(t, err.Error(), "remove automation Docker container")
	cleanupErr := executorCleanupFailure(err)
	require.Error(t, cleanupErr)
	require.Contains(t, cleanupErr.Error(), "remove automation Docker container")
	require.True(t, client.removedValue())
}

func TestDockerExecutorStartupValidationFailsClosed(t *testing.T) {
	_, err := newDockerExecutorWithClient(&fakeDockerClient{}, DockerExecutorConfig{})
	require.Error(t, err)
	require.Contains(t, err.Error(), "MESHX_AUTOMATION_DOCKER_IMAGES")

	_, err = newDockerExecutorWithClient(&fakeDockerClient{pingErr: fmt.Errorf("daemon unavailable")}, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.Error(t, err)
	require.ErrorIs(t, err, ErrDockerUnavailable)
	require.Contains(t, err.Error(), "connect to Docker")
	require.Equal(t, ExecutorFailureDockerUnavailable, ExecutorFailureKindFor(err))

	_, err = newDockerExecutorWithClient(&fakeDockerClient{imageInspectErr: map[string]error{"ubuntu:missing": errdefs.NotFound(fmt.Errorf("missing image"))}}, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:missing"}})
	require.Error(t, err)
	require.Contains(t, err.Error(), "inspect Docker image")
	require.Equal(t, ExecutorFailureImageMissing, ExecutorFailureKindFor(err))

	client := &fakeDockerClient{}
	_, err = newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04", "node-22": "node:22"}})
	require.NoError(t, err)
	require.Equal(t, []string{"node:22", "ubuntu:24.04"}, client.inspectedImagesValue())
}

func TestDockerExecutorCloseClosesClient(t *testing.T) {
	client := &fakeDockerClient{}
	executor, err := newDockerExecutorWithClient(client, DockerExecutorConfig{Images: map[string]string{"ubuntu-latest": "ubuntu:24.04"}})
	require.NoError(t, err)

	require.NoError(t, executor.Close())
	require.True(t, client.closedValue())
}

func fakeDockerJobIdentity() JobExecutionIdentity {
	return JobExecutionIdentity{
		QueueJobID:  42,
		WorkspaceID: "ws-1",
		RunID:       "wfr_1",
		JobRunID:    "wfj_1",
		JobID:       "build",
	}
}

type fakeDockerClient struct {
	mu              sync.Mutex
	pingErr         error
	imageInspectErr map[string]error
	inspectedImages []string
	createErr       error
	createConfig    *container.Config
	hostConfig      *container.HostConfig
	startErr        error
	started         bool
	killed          bool
	removed         bool
	execOptions     []container.ExecOptions
	attachOutputs   []fakeDockerAttachOutput
	inspectExit     []int
	inspectRunning  []bool
	killErr         error
	removeDelay     time.Duration
	removeErr       error
	closed          bool
}

type fakeDockerAttachOutput struct {
	stdout                 string
	stderr                 string
	blockUntilContextDone  bool
	blockUntilAttachClosed bool
}

func (c *fakeDockerClient) Ping(context.Context) (types.Ping, error) {
	return types.Ping{}, c.pingErr
}

func (c *fakeDockerClient) ImageInspectWithRaw(_ context.Context, imageID string) (dockerimage.InspectResponse, []byte, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.inspectedImages = append(c.inspectedImages, imageID)
	if c.imageInspectErr != nil {
		if err := c.imageInspectErr[imageID]; err != nil {
			return dockerimage.InspectResponse{}, nil, err
		}
	}
	return dockerimage.InspectResponse{ID: imageID}, nil, nil
}

func (c *fakeDockerClient) ContainerCreate(_ context.Context, config *container.Config, hostConfig *container.HostConfig, _ *network.NetworkingConfig, _ string) (container.CreateResponse, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.createErr != nil {
		return container.CreateResponse{}, c.createErr
	}
	c.createConfig = config
	c.hostConfig = hostConfig
	return container.CreateResponse{ID: "container-1"}, nil
}

func (c *fakeDockerClient) ContainerStart(context.Context, string, container.StartOptions) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.started = true
	return c.startErr
}

func (c *fakeDockerClient) ContainerExecCreate(_ context.Context, _ string, options container.ExecOptions) (container.ExecCreateResponse, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.execOptions = append(c.execOptions, options)
	return container.ExecCreateResponse{ID: fmt.Sprintf("exec-%d", len(c.execOptions))}, nil
}

func (c *fakeDockerClient) ContainerExecAttach(ctx context.Context, _ string, _ container.ExecAttachOptions) (types.HijackedResponse, error) {
	c.mu.Lock()
	idx := len(c.execOptions) - 1
	var output fakeDockerAttachOutput
	if idx >= 0 && idx < len(c.attachOutputs) {
		output = c.attachOutputs[idx]
	}
	c.mu.Unlock()
	server, client := net.Pipe()
	go func() {
		defer server.Close()
		if output.blockUntilContextDone {
			<-ctx.Done()
			return
		}
		if output.blockUntilAttachClosed {
			_, _ = server.Read(make([]byte, 1))
			return
		}
		if output.stdout != "" {
			_, _ = stdcopy.NewStdWriter(server, stdcopy.Stdout).Write([]byte(output.stdout))
		}
		if output.stderr != "" {
			_, _ = stdcopy.NewStdWriter(server, stdcopy.Stderr).Write([]byte(output.stderr))
		}
	}()
	return types.NewHijackedResponse(client, ""), nil
}

func (c *fakeDockerClient) ContainerExecInspect(_ context.Context, execID string) (container.ExecInspect, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	idx := 0
	if strings.HasPrefix(execID, "exec-") {
		_, _ = fmt.Sscanf(execID, "exec-%d", &idx)
		idx--
	}
	exitCode := 0
	if idx >= 0 && idx < len(c.inspectExit) {
		exitCode = c.inspectExit[idx]
	}
	running := false
	if idx >= 0 && idx < len(c.inspectRunning) {
		running = c.inspectRunning[idx]
	}
	return container.ExecInspect{ExecID: execID, ExitCode: exitCode, Running: running}, nil
}

func (c *fakeDockerClient) ContainerKill(context.Context, string, string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.killed = true
	return c.killErr
}

func (c *fakeDockerClient) ContainerRemove(ctx context.Context, _ string, _ container.RemoveOptions) error {
	c.mu.Lock()
	delay := c.removeDelay
	removeErr := c.removeErr
	c.mu.Unlock()
	if delay > 0 {
		timer := time.NewTimer(delay)
		defer timer.Stop()
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-timer.C:
		}
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.removed = true
	return removeErr
}

func (c *fakeDockerClient) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.closed = true
	return nil
}

func (c *fakeDockerClient) killedValue() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.killed
}

func (c *fakeDockerClient) removedValue() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.removed
}

func (c *fakeDockerClient) closedValue() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.closed
}

func (c *fakeDockerClient) inspectedImagesValue() []string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return append([]string(nil), c.inspectedImages...)
}
