package logging

import (
	"fmt"
	"reflect"
	"strings"
)

// RedactedPlaceholder is the literal string substituted for sensitive field
// values when redaction is applied.
const RedactedPlaceholder = "[REDACTED]"

// sensitiveKeys contains the normalized (lowercased, alphanumeric) field names
// whose values must never appear in log output in plaintext. Normalization
// (see normalizeKey) is applied to candidate keys before lookup so that
// "Set-Cookie", "set_cookie", and "SetCookie" all match the entry "setcookie".
//
// Keep this list aligned with CLAUDE.md Phase 0 requirements and PROJECT.md §5.8.
var sensitiveKeys = map[string]struct{}{
	"password":      {},
	"cookie":        {},
	"mamid":         {},
	"token":         {},
	"secret":        {},
	"authorization": {},
	"apikey":        {},
	"setcookie":     {},
	"rawcookie":     {},
}

// Redact returns a deep-copied representation of v with values of any
// recognized sensitive field replaced by RedactedPlaceholder. The result is
// safe to pass to zerolog's .Interface() or to encoding/json.Marshal.
//
// Redaction inspects struct field names (and their `json:` tags when present),
// map keys (strings only), pointer/interface targets, and slice/array elements.
// Unexported struct fields are dropped from the output, because reflection
// cannot read them without unsafe access and they would not appear in standard
// JSON output anyway.
func Redact(v any) any {
	if v == nil {
		return nil
	}
	return redactValue(reflect.ValueOf(v))
}

func redactValue(v reflect.Value) any {
	if !v.IsValid() {
		return nil
	}

	switch v.Kind() {
	case reflect.Pointer, reflect.Interface:
		if v.IsNil() {
			return nil
		}
		return redactValue(v.Elem())

	case reflect.Struct:
		t := v.Type()
		out := make(map[string]any, v.NumField())
		for i := 0; i < v.NumField(); i++ {
			field := t.Field(i)
			if !field.IsExported() {
				continue
			}
			name := fieldOutputName(field)
			if isSensitive(name) {
				out[name] = RedactedPlaceholder
				continue
			}
			out[name] = redactValue(v.Field(i))
		}
		return out

	case reflect.Map:
		out := make(map[string]any, v.Len())
		iter := v.MapRange()
		for iter.Next() {
			keyStr := fmt.Sprint(iter.Key().Interface())
			if isSensitive(keyStr) {
				out[keyStr] = RedactedPlaceholder
				continue
			}
			out[keyStr] = redactValue(iter.Value())
		}
		return out

	case reflect.Slice, reflect.Array:
		// Byte slices are typically opaque payloads (raw key material, blob
		// contents); render as a length marker rather than as base64 so that
		// nothing sensitive can leak through this path.
		if v.Type().Elem().Kind() == reflect.Uint8 {
			return fmt.Sprintf("[%d bytes]", v.Len())
		}
		out := make([]any, v.Len())
		for i := 0; i < v.Len(); i++ {
			out[i] = redactValue(v.Index(i))
		}
		return out

	default:
		return v.Interface()
	}
}

// fieldOutputName returns the name a JSON encoder would use for the field:
// the `json` tag if present (minus options), otherwise the Go field name.
func fieldOutputName(f reflect.StructField) string {
	tag, ok := f.Tag.Lookup("json")
	if !ok {
		return f.Name
	}
	name, _, _ := strings.Cut(tag, ",")
	if name == "" || name == "-" {
		return f.Name
	}
	return name
}

func isSensitive(name string) bool {
	_, ok := sensitiveKeys[normalizeKey(name)]
	return ok
}

// normalizeKey collapses a field name to lowercase with all non-alphanumeric
// characters stripped, so that "Set-Cookie", "set_cookie", "SetCookie", and
// "setCookie" all map to the same canonical key "setcookie".
func normalizeKey(s string) string {
	var b strings.Builder
	b.Grow(len(s))
	for _, r := range s {
		switch {
		case r >= 'A' && r <= 'Z':
			b.WriteRune(r + ('a' - 'A'))
		case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
			b.WriteRune(r)
		}
	}
	return b.String()
}
