//go:build darwin
// +build darwin

package platform

import (
	"testing"

	"github.com/safedep/pmg/sandbox"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestParseSeatbeltLogPayload(t *testing.T) {
	msg := `Sandbox: node(123) deny(1) file-write-data /Users/dev/project/.env ` +
		seatbeltLogMessage("run-1", "file-write", "/Users/dev/project/.env")

	payload, ok := parseSeatbeltLogPayload(msg)
	require.True(t, ok)
	assert.Equal(t, "run-1", payload.RunID)
	assert.Equal(t, "file-write", payload.Kind)
	assert.Equal(t, "/Users/dev/project/.env", payload.Target)
}

func TestParseSeatbeltLogPayloadEmptyTarget(t *testing.T) {
	msg := `Sandbox: node(123) deny(1) default ` + seatbeltLogMessage("run-1", "default", "")

	payload, ok := parseSeatbeltLogPayload(msg)
	require.True(t, ok)
	assert.Equal(t, "run-1", payload.RunID)
	assert.Equal(t, "default", payload.Kind)
	assert.Empty(t, payload.Target)
}

func TestExtractSeatbeltDeniedPath(t *testing.T) {
	payload := &seatbeltLogPayload{
		RunID:  "run-1",
		Kind:   "file-read",
		Target: "**/.env",
	}

	raw := `Sandbox: node(123) deny(1) file-read-data ./.env ` + seatbeltLogMessage("run-1", "file-read", "**/.env")
	assert.Equal(t, "./.env", extractSeatbeltDeniedPath(raw, payload))
}

func TestDecodeSeatbeltLogEntries(t *testing.T) {
	data := []byte(`[
		{"eventMessage":"entry-1","process":"node"},
		{"eventMessage":"entry-2","process":"npm"}
	]`)

	entries, err := decodeSeatbeltLogEntries(data)
	require.NoError(t, err)
	require.Len(t, entries, 2)
	assert.Equal(t, "entry-1", entries[0].EventMessage)
	assert.Equal(t, "npm", entries[1].Process)
}

func TestExtractSeatbeltViolations(t *testing.T) {
	entries := []seatbeltLogEntry{
		{
			EventMessage: "Sandbox deny " + seatbeltLogMessage("run-1", "file-read", "/tmp/.env"),
			Process:      "node",
		},
		{
			EventMessage: "Sandbox deny " + seatbeltLogMessage("run-2", "file-write", "/tmp/out"),
			Process:      "npm",
		},
	}

	violations := extractSeatbeltViolations(entries, "run-1")
	require.Len(t, violations, 1)
	assert.Equal(t, sandbox.ViolationKindFSRead, violations[0].Kind)
	assert.Equal(t, "file-read", violations[0].RawKind)
	assert.Equal(t, "/tmp/.env", violations[0].Target)
	assert.Equal(t, "/tmp/.env", violations[0].RuleTarget)
	assert.Equal(t, "node", violations[0].Process)
}

// Regression: a denial that hits the catch-all `(deny default ...)` rule
// must still be classified as the typed kind (and get a typed label) by
// recovering the verb from the sandbox-exec preamble. Without this, the
// actual failure-causing operation (e.g. a write) gets buried below
// incidental typed reads in primaryViolation ranking.
func TestExtractSeatbeltViolationsRecoversTypedKindFromDefaultDeny(t *testing.T) {
	entries := []seatbeltLogEntry{
		{
			EventMessage: `Sandbox: node(59437) deny(1) file-write-data /Users/dev/project/.astro/types.d.ts ` +
				seatbeltLogMessage("run-1", "default", ""),
			Process: "node",
		},
		{
			EventMessage: `Sandbox: node(59438) deny(1) network-outbound 1.2.3.4:443 ` +
				seatbeltLogMessage("run-1", "default", ""),
			Process: "node",
		},
	}

	violations := extractSeatbeltViolations(entries, "run-1")
	require.Len(t, violations, 2)

	// File write hit the catch-all deny — Kind is recovered, RawKind stays
	// "default" to preserve the "catch-all match" signal, and the label
	// reflects the recovered kind so users see "write access denied".
	assert.Equal(t, sandbox.ViolationKindFSWrite, violations[0].Kind)
	assert.Equal(t, "default", violations[0].RawKind)
	assert.Equal(t, "/Users/dev/project/.astro/types.d.ts", violations[0].Target)
	assert.Equal(t, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel)

	// Network denial: kind recovered from the verb, and the operand is an
	// address rather than a path, so it comes from the raw-token fallback.
	assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[1].Kind)
	assert.Equal(t, "default", violations[1].RawKind)
	assert.Equal(t, "1.2.3.4:443", violations[1].Target)
	assert.Equal(t, "network connect denied: 1.2.3.4:443", violations[1].RuleLabel)
}

// Regression: a network-bind denial (e.g. httptest listeners under go test)
// must surface as a typed network violation with the denied address, not as a
// bare generic deny that loses primary ranking to incidental fs noise.
func TestExtractSeatbeltViolationsRecoversNetworkBind(t *testing.T) {
	entries := []seatbeltLogEntry{
		{
			EventMessage: `Sandbox: scratch(36119) deny(1) network-bind local:*:0 ` +
				seatbeltLogMessage("run-1", "default", ""),
			Process: "scratch",
		},
	}

	violations := extractSeatbeltViolations(entries, "run-1")
	require.Len(t, violations, 1)
	assert.Equal(t, sandbox.ViolationKindNetworkBind, violations[0].Kind)
	assert.Equal(t, "default", violations[0].RawKind)
	assert.Equal(t, "local:*:0", violations[0].Target)
	assert.Equal(t, "network bind denied: local:*:0", violations[0].RuleLabel)
}

func TestInferSeatbeltKindFromRawLog(t *testing.T) {
	tests := []struct {
		name      string
		raw       string
		wantKind  sandbox.ViolationKind
		wantLabel string
		wantOK    bool
	}{
		{
			name:      "file-write-data maps to fs_write",
			raw:       `Sandbox: node(1) deny(1) file-write-data /tmp/x`,
			wantKind:  sandbox.ViolationKindFSWrite,
			wantLabel: "file-write",
			wantOK:    true,
		},
		{
			name:      "file-write-unlink maps to fs_delete_or_rename",
			raw:       `Sandbox: node(1) deny(1) file-write-unlink /tmp/x`,
			wantKind:  sandbox.ViolationKindFSDeleteOrRename,
			wantLabel: "file-write-unlink",
			wantOK:    true,
		},
		{
			name:      "file-rename maps to fs_delete_or_rename",
			raw:       `Sandbox: node(1) deny(1) file-rename /tmp/a`,
			wantKind:  sandbox.ViolationKindFSDeleteOrRename,
			wantLabel: "file-write-unlink",
			wantOK:    true,
		},
		{
			name:      "file-read-metadata maps to fs_read",
			raw:       `Sandbox: node(1) deny(1) file-read-metadata /tmp/x`,
			wantKind:  sandbox.ViolationKindFSRead,
			wantLabel: "file-read",
			wantOK:    true,
		},
		{
			name:      "process-exec maps to exec",
			raw:       `Sandbox: node(1) deny(1) process-exec /usr/bin/curl`,
			wantKind:  sandbox.ViolationKindExec,
			wantLabel: "process-exec",
			wantOK:    true,
		},
		{
			name:     "process-fork is not exec",
			raw:      `Sandbox: node(1) deny(1) process-fork`,
			wantKind: sandbox.ViolationKindGenericDeny,
			wantOK:   false,
		},
		{
			name:      "network-outbound maps to network_connect",
			raw:       `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`,
			wantKind:  sandbox.ViolationKindNetworkConnect,
			wantLabel: "network-outbound",
			wantOK:    true,
		},
		{
			name:      "network-bind maps to network_bind",
			raw:       `Sandbox: scratch(1) deny(1) network-bind local:*:0`,
			wantKind:  sandbox.ViolationKindNetworkBind,
			wantLabel: "network-bind",
			wantOK:    true,
		},
		{
			name:     "log without deny prefix is not recognized",
			raw:      `something unrelated`,
			wantKind: sandbox.ViolationKindGenericDeny,
			wantOK:   false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			kind, label, ok := inferSeatbeltKindFromRawLog(tc.raw)
			assert.Equal(t, tc.wantOK, ok)
			assert.Equal(t, tc.wantKind, kind)
			if tc.wantOK {
				assert.Equal(t, tc.wantLabel, label)
			}
		})
	}
}

// Normative copy, quoted by docs (M0.6); changes here are breaking.
const lockdownDirectDenialMessage = "direct network access blocked by network_via_proxy_only — traffic must flow through the PMG proxy (a tool may have ignored HTTP_PROXY/HTTPS_PROXY)"

func TestExtractSeatbeltViolationsLockdownDirectDenial(t *testing.T) {
	entries := []seatbeltLogEntry{
		{
			EventMessage: `Sandbox: curl(123) deny(1) network-outbound ` +
				seatbeltLogMessage("run-1", "network-outbound", "direct"),
			Process: "curl",
		},
		{
			EventMessage: `Sandbox: curl(124) deny(1) network-outbound ` +
				seatbeltLogMessage("run-1", "network-outbound", "1.2.3.4:443"),
			Process: "curl",
		},
	}

	violations := extractSeatbeltViolations(entries, "run-1")
	require.Len(t, violations, 2)

	assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[0].Kind)
	assert.Equal(t, lockdownDirectDenialMessage, violations[0].RuleLabel)

	assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[1].Kind)
	assert.Equal(t, "network connect denied: 1.2.3.4:443", violations[1].RuleLabel)
}

func TestExtractSeatbeltViolationsLockdownDenialWithPathOperand(t *testing.T) {
	entries := []seatbeltLogEntry{
		{
			EventMessage: `Sandbox: node(125) deny(1) network-outbound /private/var/run/mDNSResponder ` +
				seatbeltLogMessage("run-1", "network-outbound", "direct"),
			Process: "node",
		},
	}

	violations := extractSeatbeltViolations(entries, "run-1")
	require.Len(t, violations, 1)

	assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[0].Kind)
	assert.Equal(t, "/private/var/run/mDNSResponder", violations[0].Target)
	assert.Equal(t, lockdownDirectDenialMessage, violations[0].RuleLabel)
}
