//go:build e2e_ebpf

package e2e

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	corev1 "k8s.io/api/core/v1"
)

// e2eImage returns the full image reference, prefixed by E2E_REGISTRY if set.
func e2eImage(name, tag string) string {
	if imageRegistry != "" {
		return imageRegistry + "/" + name + ":" + tag
	}
	return name + ":" + tag
}

// e2ePullPolicy returns Never for local images, IfNotPresent for registry images.
func e2ePullPolicy() corev1.PullPolicy {
	if imageRegistry != "" {
		return corev1.PullAlways
	}
	return corev1.PullNever
}

// applyManifest applies a k8s manifest YAML, rewriting image references
// if E2E_REGISTRY is set.
func applyManifest(t *testing.T, path string) error {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("reading manifest %s: %w", path, err)
	}
	manifest := string(data)

	if imageRegistry != "" {
		manifest = strings.ReplaceAll(manifest, "image: kloak-", "image: "+imageRegistry+"/kloak-")
		manifest = strings.ReplaceAll(manifest, "imagePullPolicy: Never", "imagePullPolicy: Always")
	}

	cmd := exec.Command("kubectl", "apply", "-f", "-", "-n", testNamespace)
	cmd.Stdin = strings.NewReader(manifest)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("kubectl apply: %s: %w", string(out), err)
	}
	return nil
}

// controllerLogs fetches the latest controller log output.
func controllerLogs() string {
	out, _ := kubectl("logs", "-n", kloakNamespace, "-l",
		"app.kubernetes.io/component=controller", "--tail=200")
	return out
}

// pollDemoLogs polls `kubectl logs -l <appLabel>` every pollInterval
// until match(out) returns true or ctx expires. On expiry the most
// recent tail and the kloak controller logs are tagged onto t.Fatalf
// for postmortem; failTag identifies which subtest fired (e.g. "js"
// vs "demo-python-raw-tls") so a cascade is debuggable.
//
// Returns the final log output so the caller can run negative
// assertions on it (e.g. blocked-secret leakage).
//
// `--tail=500` matches the per-cycle log volume of the slowest demo
// (js, REQUEST_INTERVAL=1s) — gives ~70-100 cycles of headroom before
// the marker the caller is looking for can scroll out of the window.
func pollDemoLogs(t *testing.T, ctx context.Context, appLabel, failTag, failMsg string, match func(string) bool) string {
	t.Helper()
	var out string
	for {
		select {
		case <-ctx.Done():
			t.Logf("=== %s final logs ===\n%s", failTag, out)
			t.Logf("=== Controller logs ===\n%s", controllerLogs())
			t.Fatalf("[%s] %s", failTag, failMsg)
		default:
		}
		out, _ = kubectl("logs", "-n", testNamespace, "-l", appLabel, "--tail=500")
		if match(out) {
			return out
		}
		time.Sleep(pollInterval)
	}
}

// ebpfRewriteTest describes a single eBPF rewrite test case for a specific runtime.
type ebpfRewriteTest struct {
	name           string
	demoDir        string
	deploymentName string
	appLabel       string
	skip           string // if non-empty, test is skipped with this reason
}

var ebpfTests = []ebpfRewriteTest{
	{
		name:           "python",
		demoDir:        "demo-python",
		deploymentName: "demo-python",
		appLabel:       "app=demo-python",
	},
	{
		name:           "js",
		demoDir:        "demo-js",
		deploymentName: "demo-js",
		appLabel:       "app=demo-js",
	},
	{
		name:           "go",
		demoDir:        "demo-go",
		deploymentName: "demo-go",
		appLabel:       "app=demo-go",
	},
	{
		name:           "go-boringssl",
		demoDir:        "demo-go-boring",
		deploymentName: "demo-go-boring",
		appLabel:       "app=demo-go-boring",
		skip:           "BoringSSL H extraction not yet implemented",
	},
	{
		name:           "gnutls",
		demoDir:        "demo-gnutls",
		deploymentName: "demo-gnutls",
		appLabel:       "app=demo-gnutls",
		skip:           "GnuTLS H extraction not yet implemented",
	},
}

// TestEBPFRawTLSHostFiltering tests that DNS-verified host filtering works with
// raw TLS sockets (non-HTTP). A TLS echo server runs as a sidecar behind a K8s
// Service. The client resolves the Service name via DNS, which populates dns_ip_map,
// enabling host-based secret filtering at the TCP level.
func TestEBPFRawTLSHostFiltering(t *testing.T) {
	// The Service FQDN as it appears in DNS responses from CoreDNS.
	echoHostFQDN := "tls." + testNamespace + ".svc.cluster.local"

	allowedData := map[string][]byte{"api-key": []byte("REAL-ALLOWED-KEY-12345")}
	blockedData := map[string][]byte{"api-key": []byte("REAL-BLOCKED-KEY-67890")}

	createEnabledSecret(t, "secret-allowed", allowedData, nil, map[string]string{
		"getkloak.io/hosts": echoHostFQDN,
	})
	createEnabledSecret(t, "secret-blocked", blockedData, nil, map[string]string{
		"getkloak.io/hosts": "example.com",
	})

	assertShadowSecret(t, "secret-allowed", allowedData)
	assertShadowSecret(t, "secret-blocked", blockedData)

	demoManifest := filepath.Join(repoRoot, "examples", "demo-python-raw-tls", "deployment.yaml")
	if err := applyManifest(t, demoManifest); err != nil {
		t.Fatalf("failed to deploy demo-python-raw-tls: %v", err)
	}
	t.Cleanup(func() {
		_, _ = kubectl("delete", "-f", demoManifest, "-n", testNamespace, "--ignore-not-found")
	})

	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()
	if err := waitForDeploymentReady(ctx, testNamespace, "demo-python-raw-tls"); err != nil {
		t.Fatalf("demo-python-raw-tls not ready: %v", err)
	}

	// Same flake class as TestEBPFSecretRewrite — bumped to 180s + 500-line
	// tail for parity with `runEBPFRewriteTest`. This test was at the same
	// 95s edge in recent CI cascades.
	pollCtx, pollCancel := context.WithTimeout(context.Background(), 180*time.Second)
	defer pollCancel()
	out := pollDemoLogs(t, pollCtx, "app=demo-python-raw-tls", "demo-python-raw-tls",
		"timed out waiting for allowed secret in raw TLS demo logs",
		func(s string) bool { return strings.Contains(s, "REAL-ALLOWED-KEY-12345") })
	t.Logf("=== demo-python-raw-tls logs ===\n%s", out)

	if strings.Contains(out, "REAL-BLOCKED-KEY-67890") {
		t.Errorf("blocked secret should NOT be rewritten (host mismatch)")
	}
}

func TestEBPFSecretRewrite(t *testing.T) {
	// Wait for stale shadows from previous tests to be garbage-collected
	gcCtx, gcCancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer gcCancel()
	_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-allowed-kloak")
	_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-blocked-kloak")

	allowedData := map[string][]byte{"api-key": []byte("REAL-ALLOWED-KEY-12345")}
	blockedData := map[string][]byte{"api-key": []byte("REAL-BLOCKED-KEY-67890")}

	createEnabledSecret(t, "secret-allowed", allowedData, nil, map[string]string{
		"getkloak.io/hosts": "httpbin.org",
	})
	createEnabledSecret(t, "secret-blocked", blockedData, nil, map[string]string{
		"getkloak.io/hosts": "example.com",
	})

	assertShadowSecret(t, "secret-allowed", allowedData)
	assertShadowSecret(t, "secret-blocked", blockedData)

	for _, tc := range ebpfTests {
		t.Run(tc.name, func(t *testing.T) {
			runEBPFRewriteTest(t, tc)
		})
	}
}

func runEBPFRewriteTest(t *testing.T, tc ebpfRewriteTest) {
	if tc.skip != "" {
		t.Skip(tc.skip)
	}
	demoManifest := filepath.Join(repoRoot, "examples", tc.demoDir, "deployment.yaml")
	if err := applyManifest(t, demoManifest); err != nil {
		t.Fatalf("failed to deploy %s: %v", tc.demoDir, err)
	}
	t.Cleanup(func() {
		_, _ = kubectl("delete", "-f", demoManifest, "-n", testNamespace, "--ignore-not-found")
	})

	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()
	if err := waitForDeploymentReady(ctx, testNamespace, tc.deploymentName); err != nil {
		demoDesc, _ := kubectl("describe", "deployment", "-n", testNamespace, tc.deploymentName)
		t.Logf("deployment describe:\n%s", demoDesc)
		t.Fatalf("%s not ready: %v", tc.deploymentName, err)
	}

	// Two-phase wait. The 90s single-budget approach was flaky on slow CI
	// runners because it conflated three distinct delays (Node.js cold-
	// start / TLS module init, DNS+RTT to httpbin.org, and the uprobe
	// attach race) into one ceiling. Splitting them gives a clearer signal
	// when something does break:
	//
	//   Phase 1 — wait until the demo has issued at least two requests.
	//   All three demos use the same `--- Request #N ---` prefix; counting
	//   occurrences (rather than matching `Request #2` literally) stays
	//   correct after `Request #1` scrolls out of the kubectl --tail
	//   window on long phase-1 waits. Two requests means the runtime is
	//   alive AND the uprobe-attach race window has closed at least once,
	//   so any subsequent rewrite failure points at the eBPF path rather
	//   than at startup. 120s budget covers Node.js cold-start + first
	//   public-internet RTT under load.
	//
	//   Phase 2 — poll for the rewritten secret in the demo logs. 180s
	//   budget; the original 90s sat exactly at the failure edge for JS
	//   on stressed runners.
	//
	demoActiveCtx, demoActiveCancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer demoActiveCancel()
	pollDemoLogs(t, demoActiveCtx, tc.appLabel, tc.name,
		"demo runtime never reached its second request — image, network, or scheduling problem (not an eBPF rewrite issue)",
		func(s string) bool { return strings.Count(s, "Request #") >= 2 })

	pollCtx, pollCancel := context.WithTimeout(context.Background(), 180*time.Second)
	defer pollCancel()
	out := pollDemoLogs(t, pollCtx, tc.appLabel, tc.name,
		"timed out waiting for allowed secret in app logs (demo runtime is healthy — uprobe path failed to rewrite)",
		func(s string) bool { return strings.Contains(s, "REAL-ALLOWED-KEY-12345") })
	t.Logf("=== %s demo app logs ===\n%s", tc.name, out)

	if strings.Contains(out, "REAL-BLOCKED-KEY-67890") {
		t.Errorf("[%s] logs should NOT contain the real blocked secret", tc.name)
	}

	// Clean up deployment before next subtest
	_, _ = kubectl("delete", "-f", demoManifest, "-n", testNamespace, "--ignore-not-found")
	time.Sleep(2 * time.Second)
}

// TestEBPFSecretLengths verifies that secrets of different lengths (8 to 100 bytes)
// are correctly rewritten. Runs last to avoid secret name collisions.
func TestEBPFSecretLengths(t *testing.T) {
	echoHostFQDN := "tls." + testNamespace + ".svc.cluster.local"

	type lengthCase struct {
		name    string
		allowed string
		blocked string
	}
	cases := []lengthCase{
		{"8bytes", "SECRET-8", "BLOCKED8"},
		{"16bytes", "SECRET-16B-ABCDE", "BLOCKE-16B-FGHIJ"},
		{"21bytes", "SECRET-21B-ABCDEFGHIJ", "BLOCKE-21B-KLMNOPQRST"},
		{"42bytes", "SECRET-42B-ABCDEFGHIJKLMNOPQRSTUVWXYZABCDE", "BLOCKE-42B-FGHIJKLMNOPQRSTUVWXYZABCDEFGHIJ"},
		{"100bytes", "SECRET-100B-ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJ", "BLOCKE-100B-KLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRST"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			// Wait for stale secrets and shadows from previous tests to be cleaned up
			gcCtx, gcCancel := context.WithTimeout(context.Background(), 30*time.Second)
			defer gcCancel()
			_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-allowed")
			_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-blocked")
			_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-allowed-kloak")
			_ = waitForSecretAbsent(gcCtx, testNamespace, "secret-blocked-kloak")

			allowedData := map[string][]byte{"api-key": []byte(tc.allowed)}
			blockedData := map[string][]byte{"api-key": []byte(tc.blocked)}

			createEnabledSecret(t, "secret-allowed", allowedData, nil, map[string]string{
				"getkloak.io/hosts": echoHostFQDN,
			})
			createEnabledSecret(t, "secret-blocked", blockedData, nil, map[string]string{
				"getkloak.io/hosts": "example.com",
			})

			assertShadowSecret(t, "secret-allowed", allowedData)
			assertShadowSecret(t, "secret-blocked", blockedData)

			demoManifest := filepath.Join(repoRoot, "examples", "demo-python-raw-tls", "deployment.yaml")
			if err := applyManifest(t, demoManifest); err != nil {
				t.Fatalf("failed to deploy: %v", err)
			}
			t.Cleanup(func() {
				_, _ = kubectl("delete", "-f", demoManifest, "-n", testNamespace, "--ignore-not-found")
				time.Sleep(2 * time.Second)
			})

			ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
			defer cancel()
			if err := waitForDeploymentReady(ctx, testNamespace, "demo-python-raw-tls"); err != nil {
				t.Fatalf("deployment not ready: %v", err)
			}

			// Check prefix to poll for (use first 20 chars for long secrets)
			allowedCheck := tc.allowed
			if len(allowedCheck) > 20 {
				allowedCheck = allowedCheck[:20]
			}
			blockedCheck := tc.blocked
			if len(blockedCheck) > 20 {
				blockedCheck = blockedCheck[:20]
			}

			// Poll app logs for the allowed secret prefix
			pollCtx, pollCancel := context.WithTimeout(context.Background(), 90*time.Second)
			defer pollCancel()
			var out string
			for {
				select {
				case <-pollCtx.Done():
					t.Logf("=== python-raw-tls-%s final logs ===\n%s", tc.name, out)
					t.Logf("=== Controller logs ===\n%s", controllerLogs())
					t.Fatalf("allowed secret (%d bytes) not rewritten — prefix %q not found", len(tc.allowed), allowedCheck)
				default:
				}
				out, _ = kubectl("logs", "-n", testNamespace, "-l", "app=demo-python-raw-tls", "--tail=200")
				if strings.Contains(out, allowedCheck) {
					break
				}
				time.Sleep(pollInterval)
			}
			t.Logf("=== python-raw-tls-%s logs ===\n%s", tc.name, out)

			if strings.Contains(out, blockedCheck) {
				t.Errorf("blocked secret (%d bytes) should NOT be rewritten — prefix %q found in logs", len(tc.blocked), blockedCheck)
			}
		})
	}
}
