// SPDX-License-Identifier: GPL-3.0-or-later

// M10 test backfill for the ebpfgo plugin public surface.
//
// These tests cover public functions / types that shipped without direct unit
// coverage on the ebpf_nv branch:
//   - parseMinimalFunctionLine
//   - handleNetworkProtocols (via netdataapi buffer)
//   - buildNetworkProtocolsJSON JSON schema (golden output)
//   - applySocketDataLocked and the SHM flag round-trip
//   - UpdateSocketApps flag semantics (nil vs non-nil, per-cycle clear)
//   - runSocketGlobalCollector and runDNSGlobalCollector start-time guards
//   - DNS fallback loader sequencing (plan builder / flavor selection)
//   - Per-PID socket aggregation API shape (CGO-required)

package main

import (
	"bytes"
	"encoding/json"
	"strconv"
	"strings"
	"sync"
	"testing"
	"unsafe"

	"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
	"github.com/netdata/netdata/src/collectors/ebpf.plugin/ebpfgo.plugin/libbpfloader"
)

// ---- M10: parseMinimalFunctionLine ----------------------------------------

func TestParseMinimalFunctionLine(t *testing.T) {
	tests := map[string]struct {
		line     string
		wantUID  string
		wantName string
	}{
		"basic FUNCTION line": {
			line:     `FUNCTION 12345 10 "network-protocols" 3 pluginsd`,
			wantUID:  "12345",
			wantName: "network-protocols",
		},
		"with args after the name": {
			line:     `FUNCTION 99 5 "network-protocols some-arg" 3 pluginsd`,
			wantUID:  "99",
			wantName: "network-protocols",
		},
		"too few fields": {
			line: `FUNCTION 99 10 "network-protocols"`,
		},
		"empty line":          {line: ""},
		"not a FUNCTION line": {line: `CHART '' '' 'cpu' 'percentage' 'cpu' 'cpu.cpu0' 'line' 100 1 '' 'netdata' 'cpu'`},
	}
	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			uid, fname := parseMinimalFunctionLine(tc.line)
			if uid != tc.wantUID {
				t.Errorf("uid = %q, want %q", uid, tc.wantUID)
			}
			if fname != tc.wantName {
				t.Errorf("name = %q, want %q", fname, tc.wantName)
			}
		})
	}
}

// ---- M10: handleNetworkProtocols (netdataapi buffer) -----------------------

// runWithBuffer is a small helper that builds a netdataapi API over a bytes.Buffer
// so handleNetworkProtocols can write to it without a real pluginsd stdout.
func runWithBuffer(fn func(api *netdataapi.API)) string {
	var buf bytes.Buffer
	api := netdataapi.New(&buf)
	fn(api)
	return buf.String()
}

func TestHandleNetworkProtocols_NoDataYet(t *testing.T) {
	store := newSocketFunctionStore(5)
	got := runWithBuffer(func(api *netdataapi.API) {
		handleNetworkProtocols(api, store, "uid-no-data")
	})
	if !strings.Contains(got, "503") {
		t.Fatalf("expected 503 status in response, got: %s", got)
	}
	if !strings.Contains(got, "data not yet available") {
		t.Fatalf("expected 'data not yet available' message, got: %s", got)
	}
}

func TestHandleNetworkProtocols_WithData(t *testing.T) {
	store := newSocketFunctionStore(5)
	store.update(socketGlobalPublish{
		tcpDimReceivedCalls: 7,
		tcpDimSentCalls:     11,
		udpRecvCalls:        13,
		udpSendCalls:        17,
	})

	got := runWithBuffer(func(api *netdataapi.API) {
		handleNetworkProtocols(api, store, "uid-with-data")
	})

	if !strings.Contains(got, "200") {
		t.Fatalf("expected 200 status in response, got: %s", got)
	}
	if !strings.Contains(got, `"status":200`) {
		t.Fatalf("expected JSON status=200 in payload, got: %s", got)
	}
	// The data table must carry the published counters.
	if !strings.Contains(got, "TCP") || !strings.Contains(got, "UDP") {
		t.Fatalf("expected TCP and UDP rows in payload, got: %s", got)
	}
}

// ---- M10: JSON function payload schema (golden) ---------------------------

func TestJSONFunctionPayloadSchema_GoldenKeys(t *testing.T) {
	// Build a small but complete payload and assert the JSON has the schema
	// the network-viewer plugin expects. This is the schema pin: a column
	// rename or row reorder breaks this test.
	p := socketGlobalPublish{
		tcpDimReceivedCalls: 1,
		tcpDimSentCalls:     2,
		udpRecvCalls:        3,
		udpSendCalls:        4,
	}
	payload, err := buildNetworkProtocolsJSON(p, 5, 1_700_000_000)
	if err != nil {
		t.Fatalf("buildNetworkProtocolsJSON: %v", err)
	}

	var raw map[string]any
	if err := json.Unmarshal([]byte(payload), &raw); err != nil {
		t.Fatalf("unmarshal: %v\npayload: %s", err, payload)
	}

	requiredKeys := []string{
		"status", "type", "update_every", "has_history", "help",
		"data", "columns", "default_sort_column", "charts",
		"default_charts", "group_by", "expires",
	}
	for _, k := range requiredKeys {
		if _, ok := raw[k]; !ok {
			t.Errorf("missing top-level key %q in payload: %s", k, payload)
		}
	}

	requiredColumns := []string{
		"Transport", "Family", "Received", "Sent", "Errors",
		"ConnActive", "ConnEstablished", "ConnPassive", "ConnReset",
		"SegsTotal", "SegsRetransmitted", "DatagramsNoPort",
	}
	cols, ok := raw["columns"].(map[string]any)
	if !ok {
		t.Fatalf("columns is not an object: %s", payload)
	}
	for _, c := range requiredColumns {
		if _, ok := cols[c]; !ok {
			t.Errorf("missing column %q in payload: %s", c, payload)
		}
	}

	// Sent must come after Received in the column order — the chart
	// definition ("Traffic") sorts by the column order.
	if got := mustIndexString(cols["Received"].(map[string]any)["index"]); got != "2" {
		t.Errorf("Received index = %s, want 2", got)
	}
	if got := mustIndexString(cols["Sent"].(map[string]any)["index"]); got != "3" {
		t.Errorf("Sent index = %s, want 3", got)
	}
}

func mustIndexString(v any) string {
	switch x := v.(type) {
	case string:
		return x
	case float64:
		return strconv.FormatInt(int64(x), 10)
	}
	return ""
}

// ---- M10: UpdateSocketApps flag semantics / SHM flag round-trip -----------

// TestUpdateSocketApps_SetsFlag verifies that UpdateSocketApps sets the SOCKET
// flag when called with a non-nil, non-empty slice.
func TestUpdateSocketApps_SetsFlag(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{{PID: 1, BytesSent: 1}}, 10)
	if store.activeModules&ebpfgoSHMFlagSocket == 0 {
		t.Fatal("UpdateSocketApps did not set the SOCKET flag")
	}
}

// TestUpdateSocketApps_NilDoesNotSetFlag verifies that the failure-path caller
// (clearSocketApps passes nil) does not set the SOCKET flag; the caller is
// responsible for clearing it via MarkSocketInactive before calling here.
func TestUpdateSocketApps_NilDoesNotSetFlag(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	store.UpdateSocketApps(nil, 0)
	if store.activeModules&ebpfgoSHMFlagSocket != 0 {
		t.Fatal("UpdateSocketApps(nil) set the SOCKET flag; it must not")
	}
}

// TestUpdateSocketApps_NilDoesNotClearExistingFlag verifies that a nil call
// does not disturb a flag that was already set by a prior successful cycle.
// The flag persists until MarkSocketInactive() clears it.
func TestUpdateSocketApps_NilDoesNotClearExistingFlag(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{{PID: 1, BytesSent: 1}}, 10)
	store.UpdateSocketApps(nil, 0) // simulate a failed cycle without prior MarkSocketInactive
	if store.activeModules&ebpfgoSHMFlagSocket == 0 {
		t.Fatal("UpdateSocketApps(nil) cleared the SOCKET flag; it must not")
	}
}

func TestSHMFlagRoundTrip_ResetsAfterPublish(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	// The SOCKET flag is set by UpdateSocketApps when called with real data.
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{{PID: 1, BytesSent: 100}}, 10)
	store.UpdateApps([]libbpfloader.CachestatAppSnapshot{
		{Pid: 1, Ppid: 1, Ct: 100},
	})
	if store.activeModules&ebpfgoSHMFlagCachestat == 0 {
		t.Fatal("UpdateApps did not set the CACHESTAT flag")
	}
	if store.activeModules&ebpfgoSHMFlagSocket == 0 {
		t.Fatal("UpdateSocketApps flag was lost across UpdateApps")
	}

	// Publish clears the CACHESTAT bit each cycle; the SOCKET bit persists
	// until MarkSocketInactive() is called when the socket goroutine exits.
	if err := store.Publish(nil); err != nil {
		t.Fatalf("Publish(nil): %v", err)
	}
	if store.activeModules&ebpfgoSHMFlagCachestat != 0 {
		t.Fatalf("CACHESTAT bit still set after Publish: activeModules = %#x", store.activeModules)
	}
	if store.activeModules&ebpfgoSHMFlagSocket == 0 {
		t.Fatal("SOCKET bit was cleared by Publish; should persist until MarkSocketInactive")
	}

	// MarkSocketInactive clears the SOCKET bit (called on socket goroutine exit).
	store.MarkSocketInactive()
	if store.activeModules != 0 {
		t.Fatalf("activeModules after MarkSocketInactive = %#x, want 0", store.activeModules)
	}
}

// ---- M10: applySocketDataLocked direct coverage ---------------------------

func TestApplySocketDataLocked_ZeroesSocketForPIDsGone(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	// First, populate both cachestat and socket for PID 10.
	store.UpdateApps([]libbpfloader.CachestatAppSnapshot{
		{Pid: 10, Ppid: 1, Ct: 100, MarkPageAccessed: 50},
	})
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{
		{PID: 10, BytesSent: 1000},
	}, 10)

	// Now UpdateApps with no socket entry for PID 10 (simulating PID exit).
	store.UpdateSocketApps(nil, 0)
	store.UpdateApps([]libbpfloader.CachestatAppSnapshot{
		{Pid: 10, Ppid: 1, Ct: 101, MarkPageAccessed: 55},
	})

	// applySocketDataLocked must have zeroed the socket field on the existing
	// entry because socketData no longer contains PID 10.
	snap := store.Snapshot()
	if len(snap) != 1 {
		t.Fatalf("Snapshot len = %d, want 1", len(snap))
	}
	if snap[0].socket != (ebpfSocketPublishApps{}) {
		t.Errorf("PID %d socket data after exit = %+v, want zero", snap[0].pid, snap[0].socket)
	}
}

func TestApplySocketDataLocked_AppendsSocketOnlyPIDs(t *testing.T) {
	store := NewCachestatSharedMemoryStore()
	// Cycle 1: establish raw-counter baseline (deltas suppressed on first cycle).
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{
		{PID: 30, BytesSent: 0, CallUDPSent: 0},
		{PID: 10, BytesReceived: 0, CallTCPReceived: 0},
	}, 10)
	// Cycle 2: delta = cycle2 - cycle1 = the values below.
	store.UpdateSocketApps([]libbpfloader.SocketPIDEntry{
		{PID: 30, BytesSent: 3000, CallUDPSent: 4},
		{PID: 10, BytesReceived: 1000, CallTCPReceived: 2},
	}, 10)

	// No cachestat data; socket data must build entries directly.
	snap := store.Snapshot()
	if len(snap) != 2 {
		t.Fatalf("Snapshot len = %d, want 2", len(snap))
	}
	// Entries are sorted ascending by pid.
	if snap[0].pid != 10 || snap[1].pid != 30 {
		t.Fatalf("pids = %d,%d, want 10,30", snap[0].pid, snap[1].pid)
	}
	if snap[0].socket.BytesReceived != 1000 {
		t.Errorf("PID 10 BytesReceived = %d, want 1000", snap[0].socket.BytesReceived)
	}
	if snap[1].socket.BytesSent != 3000 {
		t.Errorf("PID 30 BytesSent = %d, want 3000", snap[1].socket.BytesSent)
	}
}

// ---- M10: runSocketGlobalCollector / runDNSGlobalCollector guards ---------

func TestRunSocketGlobalCollector_NilHandleIsNoOp(t *testing.T) {
	// A nil handle must not panic. The function returns immediately.
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("nil handle panicked: %v", r)
		}
	}()
	runSocketGlobalCollector(nil, nil, make(chan struct{}), 5, nil, nil, false)
}

func TestRunSocketGlobalCollector_NilRuntimeIsNoOp(t *testing.T) {
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("nil runtime panicked: %v", r)
		}
	}()
	runSocketGlobalCollector(nil, &SocketLegacyHandle{}, make(chan struct{}), 5, nil, nil, false)
}

// runDNSGlobalCollector is harder to exercise without a real DNS runtime;
// the start-time nil-check is the most valuable guard. Mirrored here.
func TestRunDNSGlobalCollector_NilHandleIsNoOp(t *testing.T) {
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("nil DNS handle panicked: %v", r)
		}
	}()
	runDNSGlobalCollector(nil, make(chan struct{}), 5)
}

// ---- M10: DNS fallback loader sequencing (per-query flag plumbing) ------

func TestBuildDNSLegacyPlan_FlavorAndSelectorInvariantToPerQuery(t *testing.T) {
	// PerQueryTracking must not affect the plan — only the runtime attach
	// decides whether to open the AF_PACKET capture socket.
	cases := []DNSLegacyConfig{
		defaultDNSLegacyConfig(),
		func() DNSLegacyConfig {
			c := defaultDNSLegacyConfig()
			c.KernelVersion = 328704 // 5.4
			c.ObjectFlavor = "buffer"
			return c
		}(),
		func() DNSLegacyConfig {
			c := defaultDNSLegacyConfig()
			c.KernelVersion = 330240 // 5.10
			c.ObjectFlavor = "buffer"
			return c
		}(),
		func() DNSLegacyConfig {
			c := defaultDNSLegacyConfig()
			c.KernelVersion = 396288 // 6.12
			c.IsDebian = true
			c.ObjectFlavor = "arena"
			return c
		}(),
	}

	for i := range cases {
		perQuery := cases[i]
		perQuery.PerQueryTracking = true
		noPerQuery := cases[i]
		noPerQuery.PerQueryTracking = false

		got1 := BuildDNSLegacyPlan(perQuery)
		got2 := BuildDNSLegacyPlan(noPerQuery)
		if got1 != got2 {
			t.Fatalf("case %d: plan differs between PerQueryTracking=true and false\n"+
				"perQuery: %+v\nnoPerQuery: %+v", i, got1, got2)
		}
	}
}

func TestDNSLegacyConfig_DefaultEnablesPerQuery(t *testing.T) {
	c := defaultDNSLegacyConfig()
	if !c.PerQueryTracking {
		t.Fatal("defaultDNSLegacyConfig().PerQueryTracking = false, want true (dns-queries function requires it)")
	}
}

// ---- M10: Per-PID socket aggregation API shape ----------------------------

// TestSocketPIDEntryLayout pins the Go-side per-PID socket entry struct layout
// against struct netdata_socket_per_pid_entry in socket_libbpf.c.  Any field
// addition, removal, or type change in SocketPIDEntry must be mirrored in the
// C aggregator or the decode will produce garbage values at runtime.
func TestSocketPIDEntryLayout(t *testing.T) {
	// PID uint32 (4) + 4 bytes alignment padding + 10 × uint64 (80) = 88 bytes.
	const wantSize = uintptr(4 + 4 + 10*8)
	if got := unsafe.Sizeof(libbpfloader.SocketPIDEntry{}); got != wantSize {
		t.Fatalf("unsafe.Sizeof(SocketPIDEntry{}) = %d, want %d; "+
			"socket_libbpf.c and socket_types.go are out of sync",
			got, wantSize)
	}
	// PID must be first so the C-produced entry decodes at the expected offset.
	if got := unsafe.Offsetof(libbpfloader.SocketPIDEntry{}.PID); got != 0 {
		t.Fatalf("unsafe.Offsetof(SocketPIDEntry.PID) = %d, want 0", got)
	}
	// BytesSent is the second field; the 4-byte pad after PID must be present.
	if got, want := unsafe.Offsetof(libbpfloader.SocketPIDEntry{}.BytesSent), uintptr(8); got != want {
		t.Fatalf("unsafe.Offsetof(SocketPIDEntry.BytesSent) = %d, want %d", got, want)
	}
}

// ---- M10: cgroups/network-viewer consumer smoke ---------------------------

// TestNetworkProtocolsFunctionTableResponseShape pins the column ordering
// netdataapi consumers (network-viewer, Cloud) rely on: Transport at index 0,
// Family at 1, Received at 2, Sent at 3. Changing the order breaks dashboards.
func TestNetworkProtocolsFunctionTableResponseShape(t *testing.T) {
	payload, err := buildNetworkProtocolsJSON(socketGlobalPublish{}, 5, 1)
	if err != nil {
		t.Fatalf("buildNetworkProtocolsJSON: %v", err)
	}
	var resp fnTableResponse
	if err := json.Unmarshal([]byte(payload), &resp); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if len(resp.Data) != 2 {
		t.Fatalf("Data len = %d, want 2", len(resp.Data))
	}
	if got, _ := resp.Data[0][0].(string); got != "TCP" {
		t.Errorf("Data[0][0] = %q, want \"TCP\"", got)
	}
	if got, _ := resp.Data[1][0].(string); got != "UDP" {
		t.Errorf("Data[1][0] = %q, want \"UDP\"", got)
	}
	if _, ok := resp.Columns["Transport"]; !ok {
		t.Error("Columns missing Transport")
	}
	if _, ok := resp.Columns["Received"]; !ok {
		t.Error("Columns missing Received")
	}
	if _, ok := resp.Columns["Sent"]; !ok {
		t.Error("Columns missing Sent")
	}
}

// TestSocketFunctionStore_ConcurrentUpdateSnapshot is a smoke test that the
// RWMutex guards active updates against concurrent snapshots. The assertion
// is structural — wg.Wait() must complete without panic or data race — and
// the race detector (when enabled) is the actual gate.
func TestSocketFunctionStore_ConcurrentUpdateSnapshot(t *testing.T) {
	store := newSocketFunctionStore(5)

	const writers = 4
	const writes = 200

	var wg sync.WaitGroup
	wg.Add(writers + 1)

	for i := range writers {
		go func(seed int) {
			defer wg.Done()
			for j := range writes {
				store.update(socketGlobalPublish{
					tcpDimReceivedCalls: uint64(seed*writes + j),
					tcpDimSentCalls:     uint64(j),
				})
			}
		}(i)
	}

	go func() {
		defer wg.Done()
		for range writers * writes {
			_, _ = store.snapshot()
		}
	}()

	wg.Wait()
}
