//go:build (darwin || linux || openbsd || freebsd) && cgo

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/containerd/errdefs"
	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
	"github.com/jkroepke/openvpn-auth-oauth2/internal/config/types"
	"github.com/jkroepke/openvpn-auth-oauth2/internal/httphandler"
	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
	"github.com/jkroepke/openvpn-auth-oauth2/internal/test/testsuite"
	"github.com/moby/moby/api/types/container"
	"github.com/moby/moby/client"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	"golang.org/x/net/nettest"
)

const entrypointScript = `#!/bin/sh
set -e

PLUGIN_SOCKET=/run/openvpn/openvpn-oauth2.sock

install -d -o nobody -g nogroup -m 0750 "$(dirname "${PLUGIN_SOCKET}")"

if [ ! -f /etc/openvpn/pki/ca.crt ]; then
  /usr/share/easy-rsa/easyrsa init-pki nopass
  /usr/share/easy-rsa/easyrsa build-ca nopass
  /usr/share/easy-rsa/easyrsa build-server-full server nopass
  /usr/share/easy-rsa/easyrsa build-client-full ${UPN} nopass
fi

if [ ! -f /etc/openvpn/password.txt ]; then
  printf password > /etc/openvpn/password.txt
fi

if [ ! -d /etc/openvpn/client-config ]; then
  mkdir /etc/openvpn/client-config
fi

cat > "/etc/openvpn/openvpn.conf" <<EOF
dev tun0
server 100.64.0.0 255.255.255.0
verb 3
ca /etc/openvpn/pki/ca.crt
key /etc/openvpn/pki/private/server.key
cert /etc/openvpn/pki/issued/server.crt
dh none
keepalive 10 60
persist-key
persist-tun
explicit-exit-notify

verify-client-cert none

tls-cert-profile preferred

inactive 65

topology subnet
proto udp
port 1194

fast-io
user nobody
group nogroup

# Does not work in containers
disable-dco

plugin /plugin/openvpn-auth-oauth2.so unix://${PLUGIN_SOCKET} /etc/openvpn/password.txt

reneg-sec 600
push "reneg-sec 0"

auth-gen-token 300 external-auth
auth-user-pass-optional
EOF

cat > "/etc/openvpn/${UPN}.ovpn" <<EOF
client
dev tun
setenv "IV_SSO" "webauth"
nobind
remote 127.0.0.1 1194 udp4
remote-cert-tls server
connect-retry-max 2
tls-cert-profile preferred
persist-tun
verb 3
management 0.0.0.0 8082
management-hold
<key>
$(cat /etc/openvpn/pki/private/${UPN}.key)
</key>
<cert>
$(openssl x509 -in /etc/openvpn/pki/issued/${UPN}.crt)
</cert>
<ca>
$(cat /etc/openvpn/pki/ca.crt)
</ca>
EOF

socat TCP-LISTEN:8081,reuseaddr,fork UNIX-CONNECT:${PLUGIN_SOCKET} &

exec openvpn --config "/etc/openvpn/openvpn.conf" --tmp-dir /tmp/
`

const pluginSocketPath = "/run/openvpn/openvpn-oauth2.sock"

func TestIT(t *testing.T) {
	t.Parallel()

	if testing.Short() {
		t.Skip("skipping test in short mode.")
	}

	if os.Getenv("CI") == "" && os.Getenv("PLUGIN_IT_TEST") != "1" {
		t.Skip("Skipping integration test, PLUGIN_IT_TEST not set")
	}

	testcontainers.SkipIfProviderIsNotHealthy(t)

	containerServer, err := testcontainers.Run(
		t.Context(), "",
		testcontainers.WithName("openvpn-auth-oauth2-it-server"),
		testcontainers.WithDockerfile(testcontainers.FromDockerfile{
			Context:    `../../`,
			Dockerfile: `./tests/plugin/Dockerfile`,
			Repo:       "testcontainers/openvpn-auth-oauth2",
			Tag:        "latest",
			KeepImage:  true,
		}),
		testcontainers.WithExposedPorts("8081/tcp", "8082/tcp"),
		testcontainers.WithWaitStrategy(wait.ForLog("POST /plugin/openvpn-auth-oauth2.so/PLUGIN_UP status=0").WithPollInterval(1*time.Second)),
		testcontainers.WithLabels(map[string]string{
			"testcontainers": "true",
		}),
		testcontainers.WithEnv(map[string]string{
			"UPN": "user@example.com",
		}),
		testcontainers.WithMounts(testcontainers.ContainerMount{
			Source: testcontainers.GenericVolumeMountSource{Name: "openvpn-auth-oauth2-it-data"},
			Target: "/etc/openvpn",
		}),
		testcontainers.WithFiles(testcontainers.ContainerFile{
			Reader:            strings.NewReader(entrypointScript),
			ContainerFilePath: "/entrypoint.sh",
			FileMode:          0o755,
		}),
		testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) {
			hostConfig.Binds = []string{"/dev/net/tun:/dev/net/tun"}
			hostConfig.CapAdd = []string{"NET_ADMIN"}
		}),
		testcontainers.WithEntrypoint("/entrypoint.sh"),
	)

	if containerServer == nil {
		require.NoError(t, err)

		return
	}

	testcontainers.CleanupContainer(t, containerServer)

	containerServerLogs, _ := getContainerLogs(t, containerServer)
	require.NoError(t, err, containerServerLogs)

	containerClient, err := testcontainers.Run(
		t.Context(), "",
		testcontainers.WithName("openvpn-auth-oauth2-it-client"),
		testcontainers.WithDockerfile(testcontainers.FromDockerfile{
			Context:    `../../`,
			Dockerfile: `./tests/plugin/Dockerfile`,
			Repo:       "testcontainers/openvpn-auth-oauth2",
			Tag:        "latest",
			KeepImage:  true,
		}),
		testcontainers.WithLabels(map[string]string{
			"testcontainers": "true",
		}),
		testcontainers.WithMounts(testcontainers.ContainerMount{
			Source: testcontainers.GenericVolumeMountSource{Name: "openvpn-auth-oauth2-it-data"},
			Target: "/etc/openvpn",
		}),
		testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) {
			hostConfig.Binds = []string{"/dev/net/tun:/dev/net/tun"}
			hostConfig.CapAdd = []string{"NET_ADMIN"}
			hostConfig.NetworkMode = container.NetworkMode("container:" + containerServer.GetContainerID())
		}),
		testcontainers.WithWaitStrategy(wait.ForLog("Need hold release from management interface").WithPollInterval(1*time.Second)),
		testcontainers.WithEntrypoint("openvpn"),
		testcontainers.WithEntrypointArgs("--config", "/etc/openvpn/user@example.com.ovpn", "--echo", "off"),
	)

	if containerClient == nil {
		require.NoError(t, err)

		return
	}

	testcontainers.CleanupContainer(t, containerClient)

	containerClientLogs, _ := getContainerLogs(t, containerClient)
	require.NoError(t, err, containerClientLogs)

	// clientListener must not be closed, because it is used by the httpClientListener.
	clientListener, err := nettest.NewLocalListener("tcp")
	require.NoError(t, err)

	pluginManagementEndpoint, err := containerServer.PortEndpoint(t.Context(), "8081", "tcp")
	require.NoError(t, err)

	conf := config.Defaults
	conf.OAuth2.OpenVPNUsernameClaim = testsuite.SubjectClaim
	conf.OpenVPN.Addr = types.URL{URL: &url.URL{Scheme: "tcp", Host: strings.TrimPrefix(pluginManagementEndpoint, "tcp://")}}
	conf.OpenVPN.Password = testsuite.Password

	suite := testsuite.New(conf)
	suite.SetupOIDCServer(t, clientListener, nil)

	conf = suite.GetConfig()
	oAuth2Client, openVPNClient := suite.SetupOpenVPNOAuth2Clients(t.Context(), t, nil)

	errOpenVPNClientCh := make(chan error, 1)

	go func(errCh chan<- error) {
		errCh <- openVPNClient.Connect(t.Context())
	}(errOpenVPNClientCh)

	select {
	case err := <-errOpenVPNClientCh:
		require.NoError(t, err)
	default:
	}

	t.Cleanup(func() {
		openVPNClient.Shutdown(t.Context())
	})

	httpHandler := httphandler.New(conf, oAuth2Client)
	httpClientListener := httptest.NewUnstartedServer(httpHandler)
	require.NoError(t, httpClientListener.Listener.Close())

	httpClientListener.Listener = clientListener
	httpClientListener.Start()
	t.Cleanup(httpClientListener.Close)

	clientManagementEndpoint, err := containerServer.PortEndpoint(t.Context(), "8082", "tcp")
	require.NoError(t, err)

	var dial net.Dialer

	tcpClient, err := dial.DialContext(t.Context(), "tcp", strings.TrimPrefix(clientManagementEndpoint, "tcp://"))
	require.NoError(t, err)

	tcpClientConn := testsuite.NewConn(tcpClient)
	tcpClientConn.ExpectMessage(t, openvpn.WelcomeBanner)
	tcpClientConn.ExpectMessage(t, ">HOLD:Waiting for hold release:0")
	tcpClientConn.SendAndExpectMessage(t, "hold release", "SUCCESS: hold release succeeded")

	err = tcpClient.SetReadDeadline(time.Now().Add(time.Second * 4))
	require.NoError(t, err)

	line, err := tcpClientConn.Reader().ReadString('\n')
	require.NoError(t, err)

	containerServerLogs, _ = getContainerLogs(t, containerServer)
	containerClientLogs, _ = getContainerLogs(t, containerClient)
	require.Contains(t, line, ">INFOMSG:WEB_AUTH", "server logs:\n%s\nclient logs:\n%s", containerServerLogs, containerClientLogs)

	webAuthURL := strings.TrimSpace(strings.TrimPrefix(line, ">INFOMSG:WEB_AUTH::"))

	jar, err := cookiejar.New(nil)
	require.NoError(t, err)

	httpClientListenerClient := httpClientListener.Client()
	httpClientListenerClient.Jar = jar

	resp, _, err := testsuite.DoHTTPRequest(t, httpClientListenerClient, "", http.MethodGet, webAuthURL, nil, http.NoBody) //nolint:bodyclose
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	time.Sleep(2 * time.Second)

	err = tcpClient.SetReadDeadline(time.Now().Add(time.Second * 4))
	require.NoError(t, err)

	line, err = tcpClientConn.Reader().ReadString('\n')
	require.NoError(t, err)

	containerServerLogs, _ = getContainerLogs(t, containerServer)
	containerClientLogs, _ = getContainerLogs(t, containerClient)
	require.Contains(t, line, ">PASSWORD:Auth-Token:", "server logs:\n%s\nclient logs:\n%s", containerServerLogs, containerClientLogs)

	dockerClient, err := testcontainers.NewDockerClientWithOpts(t.Context())
	require.NoError(t, err)
	t.Cleanup(func() { _ = dockerClient.Close() })

	_, err = dockerClient.ContainerStatPath(t.Context(), containerServer.GetContainerID(), client.ContainerStatPathOptions{Path: pluginSocketPath})
	require.NoError(t, err)

	stopTimeout := 10 * time.Second
	require.NoError(t, containerServer.Stop(t.Context(), &stopTimeout))

	containerServerLogs, err = getContainerLogs(t, containerServer)
	require.NoError(t, err)
	require.Contains(t, containerServerLogs, "plugin closed")

	_, err = dockerClient.ContainerStatPath(t.Context(), containerServer.GetContainerID(), client.ContainerStatPathOptions{Path: pluginSocketPath})
	require.Error(t, err)
	require.True(t, errdefs.IsNotFound(err), "expected plugin socket to be removed, got: %v", err)
}

func getContainerLogs(t *testing.T, ctr testcontainers.Container) (string, error) {
	t.Helper()

	cli, err := testcontainers.NewDockerClientWithOpts(t.Context())
	if err != nil {
		return "", fmt.Errorf("failed to create Docker client: %w", err)
	}

	logReader, err := cli.ContainerLogs(t.Context(), ctr.GetContainerID(), client.ContainerLogsOptions{
		ShowStderr: true,
		ShowStdout: true,
	})
	if err != nil {
		return "", fmt.Errorf("failed to get container logs: %w", err)
	}

	containerLogs, err := io.ReadAll(logReader)
	if err != nil {
		return "", fmt.Errorf("error reading container logs: %w", err)
	}

	return string(containerLogs), nil
}
