package command

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/docker/cli/cli/config"
	"github.com/docker/cli/cli/config/configfile"
	"github.com/docker/cli/cli/context/store"
	"github.com/docker/cli/cli/flags"
	"github.com/moby/moby/client"
	"gotest.tools/v3/assert"
)

func TestNewAPIClientFromFlags(t *testing.T) {
	host := "unix://path"
	if runtime.GOOS == "windows" {
		host = "npipe://./"
	}
	opts := &flags.ClientOptions{Hosts: []string{host}}
	apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{})
	assert.NilError(t, err)
	assert.Equal(t, apiClient.DaemonHost(), host)
	assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion)
}

func TestNewAPIClientFromFlagsForDefaultSchema(t *testing.T) {
	host := ":2375"
	slug := "tcp://localhost"
	if runtime.GOOS == "windows" {
		slug = "tcp://127.0.0.1"
	}
	opts := &flags.ClientOptions{Hosts: []string{host}}
	apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{})
	assert.NilError(t, err)
	assert.Equal(t, apiClient.DaemonHost(), slug+host)
	assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion)
}

func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
	var received map[string]string
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		received = map[string]string{
			"My-Header":  r.Header.Get("My-Header"),
			"User-Agent": r.Header.Get("User-Agent"),
		}
		_, _ = w.Write([]byte("OK"))
	}))
	defer ts.Close()
	host := strings.Replace(ts.URL, "http://", "tcp://", 1)
	opts := &flags.ClientOptions{Hosts: []string{host}}
	configFile := &configfile.ConfigFile{
		HTTPHeaders: map[string]string{
			"My-Header": "Custom-Value",
		},
	}

	apiClient, err := NewAPIClientFromFlags(opts, configFile)
	assert.NilError(t, err)
	assert.Equal(t, apiClient.DaemonHost(), host)
	assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion)

	// verify User-Agent is not appended to the configfile. see https://github.com/docker/cli/pull/2756
	assert.DeepEqual(t, configFile.HTTPHeaders, map[string]string{"My-Header": "Custom-Value"})

	expectedHeaders := map[string]string{
		"My-Header":  "Custom-Value",
		"User-Agent": UserAgent(),
	}
	_, err = apiClient.Ping(context.TODO(), client.PingOptions{})
	assert.NilError(t, err)
	assert.DeepEqual(t, received, expectedHeaders)
}

func TestNewAPIClientFromFlagsWithCustomHeadersFromEnv(t *testing.T) {
	var received http.Header
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		received = r.Header.Clone()
		_, _ = w.Write([]byte("OK"))
	}))
	defer ts.Close()
	host := strings.Replace(ts.URL, "http://", "tcp://", 1)
	opts := &flags.ClientOptions{Hosts: []string{host}}
	configFile := &configfile.ConfigFile{
		HTTPHeaders: map[string]string{
			"My-Header": "Custom-Value from config-file",
		},
	}

	// envOverrideHTTPHeaders should override the HTTPHeaders from the config-file,
	// so "My-Header" should not be present.
	t.Setenv(envOverrideHTTPHeaders, `one=one-value,"two=two,value",three=,four=four-value,four=four-value-override`)
	apiClient, err := NewAPIClientFromFlags(opts, configFile)
	assert.NilError(t, err)
	assert.Equal(t, apiClient.DaemonHost(), host)
	assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion)

	expectedHeaders := http.Header{
		"One":        []string{"one-value"},
		"Two":        []string{"two,value"},
		"Three":      []string{""},
		"Four":       []string{"four-value-override"},
		"User-Agent": []string{UserAgent()},
	}
	_, err = apiClient.Ping(context.TODO(), client.PingOptions{})
	assert.NilError(t, err)
	assert.DeepEqual(t, received, expectedHeaders)
}

func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
	const customVersion = "v3.3"
	const expectedVersion = "3.3"
	t.Setenv("DOCKER_API_VERSION", customVersion)
	t.Setenv("DOCKER_HOST", ":2375")

	opts := &flags.ClientOptions{}
	configFile := &configfile.ConfigFile{}
	apiclient, err := NewAPIClientFromFlags(opts, configFile)
	assert.NilError(t, err)
	assert.Equal(t, apiclient.ClientVersion(), expectedVersion)
}

func TestNewAPIClientFromFlagsWithInvalidAPIVersionFromEnv(t *testing.T) {
	t.Setenv("DOCKER_API_VERSION", "1")
	t.Setenv("DOCKER_HOST", ":2375")

	apiClient, err := NewAPIClientFromFlags(&flags.ClientOptions{}, &configfile.ConfigFile{})
	assert.ErrorContains(t, err, "invalid API version")
	assert.Check(t, apiClient == nil)
}

type fakeClient struct {
	client.Client
	pingFunc   func() (client.PingResult, error)
	version    string
	negotiated bool
}

func (c *fakeClient) Ping(_ context.Context, options client.PingOptions) (client.PingResult, error) {
	res, err := c.pingFunc()
	if options.NegotiateAPIVersion {
		if res.APIVersion != "" {
			if c.negotiated || options.ForceNegotiate {
				c.negotiated = true
			}
		}
	}
	return res, err
}

func (c *fakeClient) ClientVersion() string {
	return c.version
}

func TestInitializeFromClient(t *testing.T) {
	const defaultVersion = "v1.55"

	testcases := []struct {
		doc            string
		pingFunc       func() (client.PingResult, error)
		expectedServer ServerInfo
		negotiated     bool
	}{
		{
			doc: "successful ping",
			pingFunc: func() (client.PingResult, error) {
				return client.PingResult{Experimental: true, OSType: "linux", APIVersion: "v1.44"}, nil
			},
			expectedServer: ServerInfo{HasExperimental: true, OSType: "linux"},
			negotiated:     true,
		},
		{
			doc: "failed ping, no API version",
			pingFunc: func() (client.PingResult, error) {
				return client.PingResult{}, errors.New("failed")
			},
			expectedServer: ServerInfo{HasExperimental: true},
		},
		{
			doc: "failed ping, with API version",
			pingFunc: func() (client.PingResult, error) {
				return client.PingResult{APIVersion: "v1.44"}, errors.New("failed")
			},
			expectedServer: ServerInfo{HasExperimental: true},
			negotiated:     true,
		},
	}

	for _, tc := range testcases {
		t.Run(tc.doc, func(t *testing.T) {
			apiClient := &fakeClient{
				pingFunc: tc.pingFunc,
				version:  defaultVersion,
			}

			cli := &DockerCli{client: apiClient}
			err := cli.Initialize(flags.NewClientOptions())
			assert.NilError(t, err)
			assert.DeepEqual(t, cli.ServerInfo(), tc.expectedServer)
			assert.Equal(t, apiClient.negotiated, tc.negotiated)
		})
	}
}

// Makes sure we don't hang forever on the initial connection.
// https://github.com/docker/cli/issues/3652
func TestInitializeFromClientHangs(t *testing.T) {
	const (
		// Sized against measured scheduler stalls:
		// under CPU pressure this test sees 40-90ms stalls;
		// this should give about 5x headroom.
		// See https://github.com/docker/cli/issues/6003.
		clientInitTimeout = 500 * time.Millisecond

		// This is only a backstop against a genuine hang.
		// It should never be reached on a healthy run.
		// So, it should be fine to have a lenient timeout here.
		waitTimeout = 10 * time.Second
	)

	tmpDir := t.TempDir()
	socket := filepath.Join(tmpDir, "my.sock")
	l, err := net.Listen("unix", socket)
	assert.NilError(t, err)

	// Buffered, so the handler can record
	// that it was reached without a reader
	// having to be ready at that instant.
	receivedReqCh := make(chan struct{}, 1)
	releaseHandlerCh := make(chan struct{})

	// Simulate a server that hangs on connections.
	ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case receivedReqCh <- struct{}{}:
		default:
		}
		<-releaseHandlerCh
	}))
	ts.Listener = l
	ts.Start()
	t.Cleanup(func() {
		close(releaseHandlerCh)
		ts.Close()
	})

	opts := &flags.ClientOptions{Hosts: []string{"unix://" + socket}}
	configFile := &configfile.ConfigFile{}
	apiClient, err := NewAPIClientFromFlags(opts, configFile)
	assert.NilError(t, err)

	initErrCh := make(chan error, 1)

	go func() {
		cli := &DockerCli{client: apiClient, initTimeout: clientInitTimeout}
		err := cli.Initialize(flags.NewClientOptions())
		cli.CurrentVersion()
		initErrCh <- err
	}()

	select {
	case err := <-initErrCh:
		assert.Check(t, err)
	case <-time.After(waitTimeout):
		t.Fatal("timeout waiting for initialization to complete")
	}

	select {
	case <-receivedReqCh:
	case <-time.After(waitTimeout):
		t.Fatal("server never received an init request")
	}
}

func TestNewDockerCliAndOperators(t *testing.T) {
	outbuf := bytes.NewBuffer(nil)
	errbuf := bytes.NewBuffer(nil)

	cli, err := NewDockerCli(
		WithInputStream(io.NopCloser(strings.NewReader("some input"))),
		WithOutputStream(outbuf),
		WithErrorStream(errbuf),
	)
	assert.NilError(t, err)
	// Check streams are initialized
	assert.Check(t, cli.In() != nil)
	assert.Check(t, cli.Out() != nil)
	assert.Check(t, cli.Err() != nil)
	inputStream, err := io.ReadAll(cli.In())
	assert.NilError(t, err)
	assert.Equal(t, string(inputStream), "some input")

	// Check output stream
	_, err = fmt.Fprint(cli.Out(), "output")
	assert.NilError(t, err)
	outputStream, err := io.ReadAll(outbuf)
	assert.NilError(t, err)
	assert.Equal(t, string(outputStream), "output")
	// Check error stream
	_, err = fmt.Fprint(cli.Err(), "error")
	assert.NilError(t, err)
	errStream, err := io.ReadAll(errbuf)
	assert.NilError(t, err)
	assert.Equal(t, string(errStream), "error")
}

func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) {
	cli, err := NewDockerCli()
	assert.NilError(t, err)
	apiClient, err := client.New()
	assert.NilError(t, err)
	assert.NilError(t, cli.Initialize(flags.NewClientOptions(), WithAPIClient(apiClient)))
	assert.Check(t, cli.ContextStore() != nil)
}

func TestHooksEnabled(t *testing.T) {
	t.Run("disabled by default", func(t *testing.T) {
		// Make sure we don't depend on any existing ~/.docker/config.json
		config.SetDir(t.TempDir())
		cli, err := NewDockerCli()
		assert.NilError(t, err)

		assert.Check(t, !cli.HooksEnabled())
	})

	t.Run("enabled in configFile", func(t *testing.T) {
		configFile := `{
    "features": {
      "hooks": "true"
    }}`
		config.SetDir(t.TempDir())
		err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
		assert.NilError(t, err)
		cli, err := NewDockerCli()
		assert.NilError(t, err)
		assert.Check(t, cli.HooksEnabled())
	})

	t.Run("env var overrides configFile", func(t *testing.T) {
		configFile := `{
    "features": {
      "hooks": "true"
    }}`
		t.Setenv("DOCKER_CLI_HOOKS", "false")
		config.SetDir(t.TempDir())
		err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
		assert.NilError(t, err)
		cli, err := NewDockerCli()
		assert.NilError(t, err)
		assert.Check(t, !cli.HooksEnabled())
	})

	t.Run("legacy env var overrides configFile", func(t *testing.T) {
		configFile := `{
    "features": {
      "hooks": "true"
    }}`
		t.Setenv("DOCKER_CLI_HINTS", "false")
		config.SetDir(t.TempDir())
		err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
		assert.NilError(t, err)
		cli, err := NewDockerCli()
		assert.NilError(t, err)
		assert.Check(t, !cli.HooksEnabled())
	})
}

func TestSetGoDebug(t *testing.T) {
	t.Run("GODEBUG already set", func(t *testing.T) {
		t.Setenv("GODEBUG", "val1,val2")
		meta := store.Metadata{}
		setGoDebug(meta)
		assert.Equal(t, "val1,val2", os.Getenv("GODEBUG"))
	})
	t.Run("GODEBUG in context metadata can set env", func(t *testing.T) {
		t.Setenv("GODEBUG", "")
		meta := store.Metadata{
			Metadata: DockerContext{
				AdditionalFields: map[string]any{
					"GODEBUG": "val1,val2=1",
				},
			},
		}
		setGoDebug(meta)
		assert.Equal(t, "val1,val2=1", os.Getenv("GODEBUG"))
	})
}

func TestNewDockerCliWithCustomUserAgent(t *testing.T) {
	var received string
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		received = r.UserAgent()
		w.WriteHeader(http.StatusOK)
	}))
	defer ts.Close()
	host := strings.Replace(ts.URL, "http://", "tcp://", 1)
	opts := &flags.ClientOptions{Hosts: []string{host}}

	cli, err := NewDockerCli(
		WithUserAgent("fake-agent/0.0.1"),
	)
	assert.NilError(t, err)
	cli.currentContext = DefaultContextName
	cli.options = opts
	cli.configFile = &configfile.ConfigFile{}

	_, err = cli.Client().Ping(context.TODO(), client.PingOptions{})
	assert.NilError(t, err)
	assert.DeepEqual(t, received, "fake-agent/0.0.1")
}

func TestNewDockerCliWithAPIClientOptions(t *testing.T) {
	var received string
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		received = r.UserAgent()
		w.WriteHeader(http.StatusOK)
	}))
	defer ts.Close()
	host := strings.Replace(ts.URL, "http://", "tcp://", 1)
	opts := &flags.ClientOptions{Hosts: []string{host}}

	cli, err := NewDockerCli(
		WithAPIClientOptions(client.WithUserAgent("fake-agent/0.0.1")),
	)
	assert.NilError(t, err)
	cli.currentContext = DefaultContextName
	cli.options = opts
	cli.configFile = &configfile.ConfigFile{}

	_, err = cli.Client().Ping(t.Context(), client.PingOptions{})
	assert.NilError(t, err)
	assert.DeepEqual(t, received, "fake-agent/0.0.1")
}
