package fluentd

import (
	"bufio"
	"context"
	"maps"
	"net"
	"path/filepath"
	"runtime"
	"sync"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/moby/moby/v2/daemon/logger"
	"gotest.tools/v3/assert"
)

func TestValidateLogOptReconnectInterval(t *testing.T) {
	invalidIntervals := []string{"-1", "1", "-1s", "99ms", "11s"}
	for _, v := range invalidIntervals {
		t.Run("invalid "+v, func(t *testing.T) {
			err := ValidateLogOpt(map[string]string{asyncReconnectIntervalKey: v})
			assert.ErrorContains(t, err, "invalid value for fluentd-async-reconnect-interval:")
		})
	}

	validIntervals := []string{"100ms", "10s"}
	for _, v := range validIntervals {
		t.Run("valid "+v, func(t *testing.T) {
			err := ValidateLogOpt(map[string]string{asyncReconnectIntervalKey: v})
			assert.NilError(t, err)
		})
	}
}

func TestValidateLogOptAddress(t *testing.T) {
	// ports to try, and their results
	validPorts := map[string]int{
		"":       defaultPort,
		":":      defaultPort,
		":123":   123,
		":65535": 65535,
	}
	// paths to try, which should result in an error
	paths := []string{"/", "/some-path"}

	tests := []struct {
		addr        string
		ports       map[string]int // combinations of addr + port -> expected port
		paths       []string       // paths to append to addr, should be an error for tcp/udp
		expected    location
		expectedErr string
	}{
		{
			addr: "",
			expected: location{
				protocol: defaultProtocol,
				host:     defaultHost,
				port:     defaultPort,
			},
		},
		{
			addr:  "192.168.1.1",
			ports: validPorts,
			paths: paths,
			expected: location{
				protocol: defaultProtocol,
				host:     "192.168.1.1",
			},
		},
		{
			addr:  "[::1]",
			ports: validPorts,
			paths: paths,
			expected: location{
				protocol: defaultProtocol,
				host:     "::1",
			},
		},
		{
			addr:  "example.com",
			ports: validPorts,
			paths: paths,
			expected: location{
				protocol: defaultProtocol,
				host:     "example.com",
			},
		},
		{
			addr:  "tcp://",
			paths: paths,
			expected: location{
				protocol: "tcp",
				host:     defaultHost,
				port:     defaultPort,
			},
		},
		{
			addr:  "tcp://example.com",
			ports: validPorts,
			paths: paths,
			expected: location{
				protocol: "tcp",
				host:     "example.com",
			},
		},
		{
			addr:  "tls://",
			paths: paths,
			expected: location{
				protocol: "tls",
				host:     defaultHost,
				port:     defaultPort,
			},
		},
		{
			addr:  "tls://example.com",
			ports: validPorts,
			paths: paths,
			expected: location{
				protocol: "tls",
				host:     "example.com",
			},
		},
		{
			addr:        "://",
			expectedErr: "missing protocol scheme",
		},
		{
			addr:        "something://",
			expectedErr: "unsupported scheme: 'something'",
		},
		{
			addr:        "udp://",
			expectedErr: "unsupported scheme: 'udp'",
		},
		{
			addr:        "unixgram://",
			expectedErr: "unsupported scheme: 'unixgram'",
		},
		{
			addr:        "tcp+tls://",
			expectedErr: "unsupported scheme: 'tcp+tls'",
		},
		{
			addr:        "corrupted:c",
			expectedErr: "invalid port",
		},
		{
			addr:        "tcp://example.com:port",
			expectedErr: "invalid port",
		},
		{
			addr:        "tcp://example.com:-1",
			expectedErr: "invalid port",
		},
		{
			addr:        "tcp://example.com:65536",
			expectedErr: "invalid port",
		},
		{
			addr:        "unix://",
			expectedErr: "path is empty",
		},
		{
			addr: "unix:///some/socket.sock",
			expected: location{
				protocol: "unix",
				path:     "/some/socket.sock",
			},
		},
		{
			addr: "unix:///some/socket.sock:80", // unusual, but technically valid
			expected: location{
				protocol: "unix",
				path:     "/some/socket.sock:80",
			},
		},
	}
	for _, tc := range tests {
		if len(tc.ports) == 0 {
			tc.ports = map[string]int{"": tc.expected.port}
		}

		// always try empty paths; add paths to try if the test specifies it
		tc.paths = append([]string{""}, tc.paths...)
		for port, expectedPort := range tc.ports {
			for _, path := range tc.paths {
				address := tc.addr + port + path
				expected := tc.expected
				expected.port = expectedPort
				t.Run(address, func(t *testing.T) {
					err := ValidateLogOpt(map[string]string{addressKey: address})
					if path != "" {
						assert.ErrorContains(t, err, "should not contain a path element")
						return
					}
					if tc.expectedErr != "" {
						assert.ErrorContains(t, err, tc.expectedErr)
						return
					}

					assert.NilError(t, err)
					addr, _ := parseAddress(address)
					assert.DeepEqual(t, expected, *addr, cmp.AllowUnexported(location{}))
				})
			}
		}
	}
}

func TestValidateWriteTimeoutDuration(t *testing.T) {
	invalidDurations := []string{"-1", "1", "-1s"}
	for _, d := range invalidDurations {
		t.Run("invalid "+d, func(t *testing.T) {
			err := ValidateLogOpt(map[string]string{writeTimeoutKey: d})
			assert.ErrorContains(t, err, "invalid value for fluentd-write-timeout:")
		})
	}

	validDurations := map[string]time.Duration{
		"100ms": 100 * time.Millisecond,
		"10s":   10 * time.Second,
		"":      0,
	}
	for k, v := range validDurations {
		t.Run("valid "+k, func(t *testing.T) {
			err := ValidateLogOpt(map[string]string{writeTimeoutKey: k})
			assert.NilError(t, err)
			cfg, err := parseConfig(map[string]string{writeTimeoutKey: k})
			// This check is mostly redundant since it's checked in ValidateLogOpt as well.
			// This is here to guard against potential regressions in the future.
			assert.NilError(t, err)
			assert.Equal(t, cfg.WriteTimeout, v)
		})
	}
}

// TestReadWriteTimeoutsAreEffective tests that read and write timeout values are effective
// for fluentd.
func TestReadWriteTimeoutsAreEffective(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("windows not supported")
	}

	for _, tc := range []struct {
		name              string
		cfg               map[string]string
		connectionHandler connHandler
		rwValidator       func(ctx context.Context, f logger.Logger)
	}{
		{
			// This test case tests that writes timeout when the server is unresponsive.
			// The test ensures that instead of hanging forever, the fluentd write operation
			// returns an error when writes cannot be completed within the specified duration.
			name: "write timeout",
			cfg: map[string]string{
				"fluentd-write-timeout": "1ms",
			},
			connectionHandler: blackholeConnectionHandler,
			rwValidator: func(ctx context.Context, f logger.Logger) {
				// Attempt writing 1MiB worth of log data (all 0's) repeatedly. We should see a failure
				// after the 1st or the 2nd attempt depending on when the connection's write buffer gets
				// filled up.
				// If we don't set a write timeout on the connection, this will hang forever. But, because
				// we have a write timeout, we expect the `Log` method to return an error.
				data := make([]byte, 1024*1024)
				var err error
				for range 10 {
					err = f.Log(&logger.Message{
						Line:      data,
						Timestamp: time.Now(),
					})
					if err != nil {
						break
					}
				}

				// Checks if the error contains the expected message. The full message is of the format:
				// "fluent#write: failed to write after %d attempts".
				assert.ErrorContains(t, err, "fluent#write: failed to write after")
			},
		},
		{
			// This test case tests that reads timeout when the server is unresponsive and unable to
			// send acks back.
			name: "read timeout",
			cfg: map[string]string{
				"fluentd-read-timeout": "1ms",
				"fluentd-request-ack":  "true",
			},
			connectionHandler: noAckConnectionHandler,
			rwValidator: func(ctx context.Context, f logger.Logger) {
				data := make([]byte, 1024*1024)
				done := make(chan error, 1)
				go func() {
					// Log will hang forever if the read timeout is not set. Hence, we invoke that
					// asynchronously so that we can timeout the test if something goes wrong.
					done <- f.Log(&logger.Message{
						Line:      data,
						Timestamp: time.Now(),
					})
				}()

				select {
				case err := <-done:
					// In an ideal world, we would expect the following error to be returned by fluentd:
					// "fluent#write: error reading message response ack"
					// (Ref: https://github.com/fluent/fluent-logger-golang/blob/6b31033c91e794274fd6b77c692412ae945d7d67/fluent/fluent.go#L686)
					// However, the write method returns a generic message back to the caller and consumes
					// the original error message without returning it to the caller.
					// (Ref: https://github.com/fluent/fluent-logger-golang/blob/6b31033c91e794274fd6b77c692412ae945d7d67/fluent/fluent.go#L597)
					assert.ErrorContains(t, err, "fluent#write: failed to write after 1 attempts")
				case <-ctx.Done():
					t.Log("Test timed out, which is unexpected")
					t.Fail()
				}
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			// Create a temporary directory for the socket file.
			tmpDir := t.TempDir()
			socketFile := filepath.Join(tmpDir, "fluent-logger-golang.sock")
			l, err := net.Listen("unix", socketFile)
			assert.NilError(t, err, "unable to create listener for socket %s", socketFile)
			defer l.Close()

			// This is to guard against potential run-away test scenario so that a future change
			// doesn't cause the tests suite to timeout.
			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
			defer cancel()

			var connectedWG sync.WaitGroup
			connectedWG.Add(1)

			// Start accepting connections.
			go acceptConnection(t, ctx, l, tc.connectionHandler, &connectedWG)

			// Create a base configuration for fluentd logger, agnostic of the test.
			cfg := map[string]string{
				"fluentd-address": "unix://" + socketFile,
				// Disabling async behavior with limited retries and buffer size lets us test this in a more
				// preditable manner for failures. The "fluentd-read-timeout" flag should be equally effective
				// regardless of async being enabled/disabled.
				"fluentd-async":        "false",
				"fluentd-max-retries":  "1",
				"fluentd-retry-wait":   "10ms",
				"fluentd-buffer-limit": "1",
				logger.AttrLogTag:      "{{.Name}}/{{.FullID}}",
			}
			// Update the config with test specific configs.
			maps.Copy(cfg, tc.cfg)

			f, err := New(logger.Info{
				ContainerName: "/test-container",
				ContainerID:   "container-abcdefghijklmnopqrstuvwxyz01234567890",
				Config:        cfg,
			})
			assert.NilError(t, err)
			defer closeLoggerWithContext(ctx, f)

			// Ensure that the server is ready to accept connections since we have disabled async mode
			// in fluentd options.
			connectedWG.Wait()

			tc.rwValidator(ctx, f)
		})
	}
}

// closeLoggerWithContext enables the caller to return early if logger's Close() method is stuck.
// Fluentd's Close() will be stuck as long as there's a pending write in progress since the mutex
// associated with the connection will be locked.
// Ref: https://github.com/fluent/fluent-logger-golang/blob/6b31033c91e794274fd6b77c692412ae945d7d67/fluent/fluent.go#L600
func closeLoggerWithContext(ctx context.Context, f logger.Logger) {
	doneClose := make(chan error, 1)
	go func() {
		doneClose <- f.Close()
	}()
	// Return if either Close() returns or if the context is done.
	select {
	case <-doneClose:
	case <-ctx.Done():
	}
}

func acceptConnection(
	t *testing.T,
	ctx context.Context,
	l net.Listener,
	handleConnection connHandler,
	wg *sync.WaitGroup,
) {
	wg.Done()

	for {
		conn, err := l.Accept()
		if err != nil {
			// Unable to process the connection. This can happen if the connection is closed.
			select {
			case <-ctx.Done():
				// If the context is canceled, there's nothing for us to do here.
				return
			default:
				t.Logf("Unable to accept connection: %v", err)
				continue
			}
		}

		// Handle an incoming connection.
		go handleConnection(ctx, conn)
	}
}

type connHandler func(ctx context.Context, conn net.Conn)

func blackholeConnectionHandler(ctx context.Context, conn net.Conn) {
	// Simulate unresponsive server: do nothing with the connection. We're essentially blackholing this
	// by not reading from or writing to the connection.
	<-ctx.Done()
	_ = conn.Close()
}

func noAckConnectionHandler(ctx context.Context, conn net.Conn) {
	// Create a buffered reader to read from the connection.
	reader := bufio.NewReader(conn)
	// Read data from the connection.
	_, err := reader.ReadString('\n')
	if err != nil {
		// If there's an error reading from the connection, it means the connection is closed.
		select {
		case <-ctx.Done():
			// If the context is canceled, there's nothing for us to do here.
			return
		default:
			return
		}
	}
	// Don't write an ack back. The fluent configuration is set to expect an ack from the server.
	<-ctx.Done()
	_ = conn.Close()
}
