package audit

import (
	"bytes"
	"encoding/json"
	"net/http"
	"strings"
	"testing"
)

// TestPIIBlacklistCanonical asserts the canonical §14.3 blacklist exactly
// (order-insensitive, case-insensitive). New entries must land in the seed
// AND in this test — this is an L0 invariant, not a refactor-friendly list.
func TestPIIBlacklistCanonical(t *testing.T) {
	want := []string{
		"password", "currentPassword", "newPassword", "oldPassword",
		"previousPassword", "passwordConfirm",
		"token", "accessToken", "refreshToken", "apiToken", "apiKey", "api_key",
		"sessionKey", "session_key", "sessionToken", "session_token",
		"securityToken", "clientSecret",
		"mfaSecret", "totpCode",
		"creditCard", "cvv", "ssn", "taxId", "bankAccount",
	}
	have := map[string]struct{}{}
	for k := range PIIBodyBlacklist {
		have[strings.ToLower(k)] = struct{}{}
	}
	if len(have) != len(want) {
		t.Fatalf("PIIBodyBlacklist size = %d, want %d; entries=%v",
			len(have), len(want), keysOf(PIIBodyBlacklist))
	}
	for _, w := range want {
		if _, ok := have[strings.ToLower(w)]; !ok {
			t.Errorf("PIIBodyBlacklist missing %q (§14.3)", w)
		}
	}
}

// TestPIIHeaderBlacklistCanonical asserts §14.3 header blacklist.
func TestPIIHeaderBlacklistCanonical(t *testing.T) {
	want := []string{
		"Authorization", "Cookie", "Set-Cookie",
		"X-Api-Key", "X-Session-Token",
		"Proxy-Authorization", "X-Csrf-Token", "X-Salesforce-Session",
	}
	have := map[string]struct{}{}
	for k := range PIIHeaderBlacklist {
		have[strings.ToLower(k)] = struct{}{}
	}
	if len(have) != len(want) {
		t.Fatalf("PIIHeaderBlacklist size = %d, want %d; entries=%v",
			len(have), len(want), keysOf(PIIHeaderBlacklist))
	}
	for _, w := range want {
		if _, ok := have[strings.ToLower(w)]; !ok {
			t.Errorf("PIIHeaderBlacklist missing %q (§14.3)", w)
		}
	}
}

func keysOf[V any](m map[string]V) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

// TestScrubFieldValue covers the per-field entrypoint used by emitter (§3.6 +
// §14.3). The same canonical list replaces the C7 stub.
func TestScrubFieldValue(t *testing.T) {
	cases := []struct {
		name        string
		fieldName   string
		in          string
		wantRedact  bool
	}{
		{"password lowercase", "password", "hunter2", true},
		{"Password TitleCase", "Password", "hunter2", true},
		{"PASSWORD upper", "PASSWORD", "hunter2", true},
		{"accessToken", "accessToken", "eyJ...", true},
		{"access_token snake not in list", "access_token", "eyJ...", false},
		{"sessionKey", "sessionKey", "skey", true},
		{"session_key snake", "session_key", "skey", true},
		{"SESSION_KEY upper snake", "SESSION_KEY", "skey", true},
		{"ssn", "ssn", "000-00-0000", true},
		{"creditCard", "creditCard", "4111-1111-1111-1111", true},
		{"taxId", "taxId", "12-3456789", true},
		{"unrelated field", "firstName", "Amber", false},
		{"partial suffix must not match", "passwordHashPolicy", "strict", false},
		{"empty value still redacted", "password", "", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, redacted := ScrubFieldValue(tc.fieldName, tc.in)
			if redacted != tc.wantRedact {
				t.Fatalf("ScrubFieldValue(%q): redacted=%v want %v", tc.fieldName, redacted, tc.wantRedact)
			}
			if redacted && got != PIIScrubSentinel {
				t.Errorf("ScrubFieldValue(%q): got %q want sentinel %q", tc.fieldName, got, PIIScrubSentinel)
			}
			if !redacted && got != tc.in {
				t.Errorf("ScrubFieldValue(%q): got %q want %q (passthrough)", tc.fieldName, got, tc.in)
			}
		})
	}
}

// TestScrubJSONPayload covers the deep-walk body scrubber (§14.3):
// top-level + nested objects + array elements, case-insensitive match on
// trailing key segment, no false positives on suffix lookalikes.
func TestScrubJSONPayload(t *testing.T) {
	cases := []struct {
		name      string
		in        string
		wantKeep  []string // substrings that MUST remain
		wantGone  []string // substrings that must be replaced
		wantError bool
	}{
		{
			name:     "top-level password redacted",
			in:       `{"username":"alice","password":"hunter2"}`,
			wantKeep: []string{`"username":"alice"`, PIIScrubSentinel},
			wantGone: []string{"hunter2"},
		},
		{
			name:     "nested object",
			in:       `{"user":{"name":"alice","password":"hunter2"},"meta":{"ok":true}}`,
			wantKeep: []string{`"name":"alice"`, `"ok":true`, PIIScrubSentinel},
			wantGone: []string{"hunter2"},
		},
		{
			name:     "array of objects",
			in:       `{"items":[{"accessToken":"t1"},{"accessToken":"t2"},{"safe":"ok"}]}`,
			wantKeep: []string{`"safe":"ok"`, PIIScrubSentinel},
			wantGone: []string{"t1", "t2"},
		},
		{
			name:     "case-insensitive match",
			in:       `{"PASSWORD":"a","Password":"b","password":"c"}`,
			wantKeep: []string{PIIScrubSentinel},
			wantGone: []string{`"a"`, `"b"`, `"c"`},
		},
		{
			name:     "no false positive on suffix lookalike",
			in:       `{"passwordHashPolicy":"strict","somepassword":"xxx"}`,
			wantKeep: []string{`"strict"`, `"xxx"`}, // neither key is full-match
			wantGone: []string{PIIScrubSentinel},
		},
		{
			name:     "mfaSecret + totpCode + cvv",
			in:       `{"mfaSecret":"s","totpCode":"123456","cvv":"999"}`,
			wantKeep: []string{PIIScrubSentinel},
			wantGone: []string{`"s"`, `"123456"`, `"999"`},
		},
		{
			name:      "invalid json errors",
			in:        `{"bad":`,
			wantError: true,
		},
		{
			name:     "empty object passthrough",
			in:       `{}`,
			wantKeep: []string{`{}`},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ScrubJSONPayload([]byte(tc.in))
			if tc.wantError {
				if err == nil {
					t.Fatalf("expected error, got nil; out=%s", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			g := string(got)
			// Re-marshal canonicalization check: output must still be valid JSON.
			var probe any
			if err := json.Unmarshal(got, &probe); err != nil {
				t.Fatalf("output is not valid JSON: %v\n%s", err, g)
			}
			for _, k := range tc.wantKeep {
				if !strings.Contains(g, k) {
					t.Errorf("output missing expected substring %q\nout=%s", k, g)
				}
			}
			for _, w := range tc.wantGone {
				if strings.Contains(g, w) {
					t.Errorf("output still contains %q (should be replaced)\nout=%s", w, g)
				}
			}
		})
	}
}

// TestScrubJSONPayload_DeepNesting checks recursive walk at depth > 3.
func TestScrubJSONPayload_DeepNesting(t *testing.T) {
	in := `{"a":{"b":{"c":{"d":{"password":"deep"}}}}}`
	got, err := ScrubJSONPayload([]byte(in))
	if err != nil {
		t.Fatalf("err=%v", err)
	}
	if bytes.Contains(got, []byte("deep")) {
		t.Errorf("deep-nested secret leaked: %s", got)
	}
	if !bytes.Contains(got, []byte(PIIScrubSentinel)) {
		t.Errorf("sentinel missing: %s", got)
	}
}

// TestScrubJSONPayload_ArrayRoot checks top-level arrays.
func TestScrubJSONPayload_ArrayRoot(t *testing.T) {
	in := `[{"password":"a"},{"safe":"ok"}]`
	got, err := ScrubJSONPayload([]byte(in))
	if err != nil {
		t.Fatalf("err=%v", err)
	}
	s := string(got)
	if strings.Contains(s, `"a"`) {
		t.Errorf("secret leaked: %s", s)
	}
	if !strings.Contains(s, `"safe":"ok"`) {
		t.Errorf("non-pii value stripped: %s", s)
	}
}

// TestScrubJSONPayload_Nil covers nil / empty input (operational sanity).
func TestScrubJSONPayload_Nil(t *testing.T) {
	if _, err := ScrubJSONPayload(nil); err == nil {
		t.Errorf("expected error on nil input")
	}
	if _, err := ScrubJSONPayload([]byte("")); err == nil {
		t.Errorf("expected error on empty input")
	}
}

// TestScrubHeaders — exact-match redaction (case-insensitive) for the §14.3
// header list. Unlisted headers pass through unchanged. Input is NOT mutated
// (immutable copy semantics).
func TestScrubHeaders(t *testing.T) {
	h := http.Header{}
	h.Set("Authorization", "Bearer eyJ...")
	h.Set("Cookie", "session=abc")
	h.Set("Set-Cookie", "session=abc")
	h.Set("X-Api-Key", "k1")
	h.Set("X-Session-Token", "s1")
	h.Set("Proxy-Authorization", "Basic xxx")
	h.Set("X-Csrf-Token", "c1")
	h.Set("X-Salesforce-Session", "sess1")
	h.Set("User-Agent", "curl/8.4")
	h.Set("Content-Type", "application/json")
	// Case variant — Go canonicalizes header names but we still assert our
	// match is case-insensitive in implementation (map lookup uses canonical).
	h.Add("x-api-key", "k2")

	got := ScrubHeaders(h)

	// Non-sensitive pass-through:
	if got.Get("User-Agent") != "curl/8.4" {
		t.Errorf("User-Agent altered: %q", got.Get("User-Agent"))
	}
	if got.Get("Content-Type") != "application/json" {
		t.Errorf("Content-Type altered: %q", got.Get("Content-Type"))
	}
	// Sensitive must be redacted:
	for _, name := range []string{
		"Authorization", "Cookie", "Set-Cookie",
		"X-Api-Key", "X-Session-Token",
		"Proxy-Authorization", "X-Csrf-Token", "X-Salesforce-Session",
	} {
		vals := got.Values(name)
		if len(vals) == 0 {
			t.Errorf("%s missing after scrub", name)
			continue
		}
		for _, v := range vals {
			if v != PIIScrubSentinel {
				t.Errorf("%s not redacted: %q", name, v)
			}
		}
	}
	// Input not mutated:
	if h.Get("Authorization") != "Bearer eyJ..." {
		t.Errorf("input Header was mutated (Authorization=%q)", h.Get("Authorization"))
	}
}

// TestScrubHeaders_Nil handles zero-value input without panicking.
func TestScrubHeaders_Nil(t *testing.T) {
	got := ScrubHeaders(nil)
	if got == nil {
		t.Errorf("ScrubHeaders(nil) should return empty non-nil Header")
	}
	if len(got) != 0 {
		t.Errorf("ScrubHeaders(nil) should be empty")
	}
}

// TestScrubQueryString covers HIGH-1 fix — URL raw-query redaction using
// the shared isPIIKey blacklist. Asserts passthrough, single-key redaction,
// case-insensitive match, multi-value (repeated key) redaction, empty input,
// and malformed-escape returns input unchanged (no silent drop).
func TestScrubQueryString(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"empty", "", ""},
		{"safe passthrough", "name=alice&age=30", "age=30&name=alice"},
		{"token redacted", "token=abc&name=alice", "name=alice&token=" + sentinelEsc()},
		{"case-insensitive PASSWORD", "PASSWORD=a&b=c", "PASSWORD=" + sentinelEsc() + "&b=c"},
		{"multi-value token", "token=a&token=b&ok=yes", "ok=yes&token=" + sentinelEsc() + "&token=" + sentinelEsc()},
		{"apiToken + accessToken", "apiToken=x&accessToken=y&z=keep",
			"accessToken=" + sentinelEsc() + "&apiToken=" + sentinelEsc() + "&z=keep"},
		{"suffix lookalike kept", "passwordHashPolicy=strict", "passwordHashPolicy=strict"},
		{"special char escaped", "token=a+b c&name=bob", "name=bob&token=" + sentinelEsc()},
		// Malformed escape → return input verbatim so operator sees raw.
		{"malformed escape passthrough", "token=%zz", "token=%zz"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := ScrubQueryString(tc.in)
			if got != tc.want {
				t.Errorf("ScrubQueryString(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

// sentinelEsc returns the URL-escaped form of PIIScrubSentinel for comparing
// against ScrubQueryString / ScrubFormBody output.
func sentinelEsc() string {
	// net/url.QueryEscape("***SCRUBBED***") = "%2A%2A%2ASCRUBBED%2A%2A%2A"
	return "%2A%2A%2ASCRUBBED%2A%2A%2A"
}

// TestScrubFormBody covers HIGH-2 fix — form-urlencoded body redaction.
func TestScrubFormBody(t *testing.T) {
	cases := []struct {
		name    string
		in      string
		want    string
		wantErr bool
	}{
		{"empty errors", "", "", true},
		{"safe passthrough", "name=alice&age=30", "age=30&name=alice", false},
		{"password redacted", "password=hunter2&name=alice",
			"name=alice&password=" + sentinelEsc(), false},
		{"case-insensitive Password", "Password=hunter2&ok=yes",
			"Password=" + sentinelEsc() + "&ok=yes", false},
		{"multi-value password", "password=a&password=b&ok=1",
			"ok=1&password=" + sentinelEsc() + "&password=" + sentinelEsc(), false},
		{"clientSecret", "clientSecret=xyz&safe=yes",
			"clientSecret=" + sentinelEsc() + "&safe=yes", false},
		{"partially malformed parsed best-effort", "%zz=bad&password=h",
			"password=" + sentinelEsc(), false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ScrubFormBody([]byte(tc.in))
			if tc.wantErr {
				if err == nil {
					t.Fatalf("expected error, got nil (got=%s)", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if string(got) != tc.want {
				t.Errorf("ScrubFormBody(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

// TestScrubJSONPayload_LargePayload — performance sanity; 10k keys, half of
// which are pii. This is a smoke test, not a benchmark.
func TestScrubJSONPayload_LargePayload(t *testing.T) {
	var b strings.Builder
	b.WriteString("{")
	const N = 5000
	for i := 0; i < N; i++ {
		if i > 0 {
			b.WriteString(",")
		}
		if i%2 == 0 {
			b.WriteString(`"password":"x"`)
		} else {
			b.WriteString(`"name":"y"`)
		}
	}
	b.WriteString("}")
	// Above creates a single JSON object with duplicate keys — json.Unmarshal
	// will collapse duplicates into one-per-key. We care that ScrubJSONPayload
	// doesn't error and returns valid JSON; exact redaction count is not
	// asserted here (duplicate-key semantics belong to the parser).
	got, err := ScrubJSONPayload([]byte(b.String()))
	if err != nil {
		t.Fatalf("err=%v", err)
	}
	var probe any
	if err := json.Unmarshal(got, &probe); err != nil {
		t.Fatalf("output not valid JSON: %v", err)
	}
	if strings.Contains(string(got), `"password":"x"`) {
		t.Errorf("large-payload pii not redacted")
	}
}
