package audit

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/url"
	"sort"
	"strings"
)

// PIIScrubSentinel is the replacement string written in place of any value
// whose key matches the §14.3 blacklist (body) or §14.3 header blacklist.
// Kept identical across field-level emitter (§3.6) and request-capture
// middleware (§14.6) so the replay tool can reliably tell "redacted" apart
// from a legitimate user-written literal.
const PIIScrubSentinel = "***SCRUBBED***"

// PIIBodyBlacklist is the canonical §14.3 hardcoded blacklist of JSON / form
// / XML keys whose values must be replaced with PIIScrubSentinel before any
// persistence (both _System_AuditLog field diffs per §4.2 and
// _System_RequestCapture body capture per §14.6).
//
// Match rule: trailing key segment, case-insensitive full equality. NO regex
// or suffix match (e.g. "passwordHashPolicy" must NOT match). Recursive walk
// into nested objects and array elements.
//
// L0 invariant: entries are STANDARD authentication / credential / PII field
// names, zero tenant-specific `__c` names. Admin-editable extension belongs
// in L2 metadata (Phase 2C `_System_RequestCaptureRules`), not here.
//
// Map keys are stored in their canonical (camelCase) form; lookups use
// strings.EqualFold semantics via the isPIIKey helper.
var PIIBodyBlacklist = map[string]struct{}{
	// Passwords and password-like credentials.
	"password":         {},
	"currentPassword":  {},
	"newPassword":      {},
	"oldPassword":      {},
	"previousPassword": {},
	"passwordConfirm":  {},
	// Bearer / API / session tokens.
	"token":         {},
	"accessToken":   {},
	"refreshToken":  {},
	"apiToken":      {},
	"apiKey":        {},
	"sessionKey":    {},
	// snake_case variants: the case-insensitive full-equality match below
	// does NOT canonicalize separators, so the camelCase spelling alone
	// leaves snake_case payloads un-scrubbed (local-verify MEDIUM surfaced
	// this on the audit-log-impl branch). Both spellings are common in
	// JSON / form bodies emitted by the frontend + webhook receivers.
	"session_key":   {},
	"session_token": {},
	"api_key":       {},
	"sessionToken":  {},
	"securityToken": {},
	"clientSecret":  {},
	// MFA / OTP.
	"mfaSecret": {},
	"totpCode":  {},
	// Financial / government-id PII.
	"creditCard":  {},
	"cvv":         {},
	"ssn":         {},
	"taxId":       {},
	"bankAccount": {},
}

// PIIHeaderBlacklist is the canonical §14.3 header blacklist. Entire header
// value is replaced with PIIScrubSentinel (we don't attempt to preserve a
// prefix like `Bearer ` — any prefix itself leaks the auth-scheme choice).
//
// Matching is case-insensitive; Go's net/http canonicalizes header keys so
// a direct map probe on the canonical form is sufficient at runtime.
var PIIHeaderBlacklist = map[string]struct{}{
	"Authorization":        {},
	"Cookie":               {},
	"Set-Cookie":           {},
	"X-Api-Key":            {},
	"X-Session-Token":      {},
	"Proxy-Authorization":  {},
	"X-Csrf-Token":         {},
	"X-Salesforce-Session": {},
}

// isPIIKey reports whether name matches any entry in PIIBodyBlacklist under
// the §14.3 "case-insensitive full equality" rule. This is the single
// lookup that emitter (ScrubFieldValue) and body walker (ScrubJSONPayload)
// both funnel through — keeping them in lockstep with the blacklist.
func isPIIKey(name string) bool {
	for k := range PIIBodyBlacklist {
		if strings.EqualFold(k, name) {
			return true
		}
	}
	return false
}

// ScrubFieldValue is the per-field entry point used by the audit emitter
// when fanning field diffs out to _System_AuditLog. It preserves the
// call-site ergonomic "(value, wasRedacted)" return so the caller can emit
// a DEBUG log for observability without having to re-check membership.
//
// Returns (PIIScrubSentinel, true) when the field name is blacklisted;
// otherwise (value, false) unchanged. value is returned as-is (any type),
// but only string values are exercised by the current callsites — non-string
// values trigger the same redaction (the sentinel is a string).
func ScrubFieldValue(fieldName string, value string) (string, bool) {
	if isPIIKey(fieldName) {
		return PIIScrubSentinel, true
	}
	return value, false
}

// ScrubJSONPayload walks a JSON document (top-level object OR array) and
// replaces every value whose key matches PIIBodyBlacklist with the sentinel.
// Returns the re-marshaled JSON bytes.
//
// Errors:
//   - nil or empty payload → error ("empty payload cannot be scrubbed")
//   - invalid JSON → json.Unmarshal error passed through
//
// Top-level JSON scalars (a bare `"secret"` or `42`) are returned unchanged;
// there's no key context to match against and the design §14.3 rule is
// "match by key name" — no key, no match.
func ScrubJSONPayload(payload []byte) ([]byte, error) {
	if len(payload) == 0 {
		return nil, errors.New("audit: empty payload cannot be scrubbed")
	}
	var root any
	if err := json.Unmarshal(payload, &root); err != nil {
		return nil, err
	}
	scrubbed := scrubAny(root)
	return json.Marshal(scrubbed)
}

// scrubAny is the recursive walker. It copies maps/slices immutably — we do
// not mutate the input structure the caller passed in (even though it was
// produced by our own json.Unmarshal).
func scrubAny(v any) any {
	switch t := v.(type) {
	case map[string]any:
		out := make(map[string]any, len(t))
		for k, val := range t {
			if isPIIKey(k) {
				out[k] = PIIScrubSentinel
				continue
			}
			out[k] = scrubAny(val)
		}
		return out
	case []any:
		out := make([]any, len(t))
		for i, val := range t {
			out[i] = scrubAny(val)
		}
		return out
	default:
		return v
	}
}

// ScrubQueryString parses a URL raw query string, redacts any values whose
// key matches PIIBodyBlacklist (case-insensitive full equality via isPIIKey),
// and returns the re-encoded query string.
//
// A request like `GET /x?token=abc&safe=1` is persisted to
// _System_RequestCapture.Query as `token=***SCRUBBED***&safe=1` instead of
// leaking the bearer token verbatim.
//
// Key ordering is stabilized (sort.Strings) because url.Values is a map —
// tests would otherwise flake. Repeated keys (?k=a&k=b) preserve their
// count; each value is either redacted or passed through independently.
// On parse error (malformed escape), the string is returned unchanged so
// we don't silently drop capture rows — the replay tool can flag it.
func ScrubQueryString(rawQuery string) string {
	if rawQuery == "" {
		return ""
	}
	values, err := url.ParseQuery(rawQuery)
	if err != nil {
		// Malformed query — we cannot safely redact. Return as-is; the
		// replay/operator will see the raw form. Phase 2B may hard-drop.
		return rawQuery
	}
	keys := make([]string, 0, len(values))
	for k := range values {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var b strings.Builder
	for _, k := range keys {
		vs := values[k]
		redact := isPIIKey(k)
		for _, v := range vs {
			if b.Len() > 0 {
				b.WriteByte('&')
			}
			b.WriteString(url.QueryEscape(k))
			b.WriteByte('=')
			if redact {
				b.WriteString(url.QueryEscape(PIIScrubSentinel))
			} else {
				b.WriteString(url.QueryEscape(v))
			}
		}
	}
	return b.String()
}

// ScrubFormBody parses an application/x-www-form-urlencoded body, redacts
// values whose key matches PIIBodyBlacklist, and returns the re-encoded
// bytes. Same semantics as ScrubQueryString (same isPIIKey blacklist, same
// key-sort for stability, repeated keys preserved).
//
// Errors only when the body parses as URL-encoded but is otherwise
// malformed — url.ParseQuery is permissive (returns a partial map + error);
// we treat any partial result as sufficient and still redact it.
func ScrubFormBody(body []byte) ([]byte, error) {
	if len(body) == 0 {
		return nil, errors.New("audit: empty payload cannot be scrubbed")
	}
	// ParseQuery handles form-urlencoded the same way — tolerates partial
	// errors and returns what it could parse. Use its output, ignore the
	// error (preserving caller-visible behavior: redact what we can).
	values, _ := url.ParseQuery(string(body))
	keys := make([]string, 0, len(values))
	for k := range values {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var b strings.Builder
	for _, k := range keys {
		vs := values[k]
		redact := isPIIKey(k)
		for _, v := range vs {
			if b.Len() > 0 {
				b.WriteByte('&')
			}
			b.WriteString(url.QueryEscape(k))
			b.WriteByte('=')
			if redact {
				b.WriteString(url.QueryEscape(PIIScrubSentinel))
			} else {
				b.WriteString(url.QueryEscape(v))
			}
		}
	}
	return []byte(b.String()), nil
}

// ScrubHeaders returns a NEW http.Header with §14.3 blacklisted headers'
// values replaced by PIIScrubSentinel. The input header is never mutated
// (immutability per CLAUDE.md coding-style). Unlisted headers pass through.
//
// Behavior on nil input: returns an empty non-nil http.Header so callers can
// unconditionally read from the result.
func ScrubHeaders(h http.Header) http.Header {
	out := http.Header{}
	if h == nil {
		return out
	}
	for name, vals := range h {
		canonical := http.CanonicalHeaderKey(name)
		if _, bad := PIIHeaderBlacklist[canonical]; bad {
			// Preserve the multiplicity of header values — matters for
			// Set-Cookie which is legitimately repeated. Each value becomes
			// the sentinel; count is preserved so the replay diff can still
			// see "there were N cookies" even if contents are gone.
			redacted := make([]string, len(vals))
			for i := range vals {
				redacted[i] = PIIScrubSentinel
			}
			out[canonical] = redacted
			continue
		}
		// Passthrough: copy slice to avoid aliasing.
		cp := make([]string, len(vals))
		copy(cp, vals)
		out[canonical] = cp
	}
	return out
}
