package auth

import (
	"context"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
)

// failLimiter throttles repeated login failures keyed by "username|ip". After
// max failures it imposes a cooldown of window before any further attempt for
// that key is accepted, blunting brute force against PAM/shadow.
//
// ponytail: in-memory, single-process, fixed cooldown - correct for a single
// app instance. State is lost on restart, but only an operator restarts the
// process (an attacker can't), so persisting it would buy nothing. Source
// spoofing is handled by the network (VPN-only + trusted proxy sets XFF), not
// here. Reach for pam_faillock only if a failed web login should also lock the
// OS account against ssh/console - a different layer we deliberately don't span.
type failLimiter struct {
	mu       sync.Mutex
	attempts map[string]*attemptState
	max      int
	window   time.Duration
	sync     func(key string, s *attemptState) // optional: persist to DB
}

type attemptState struct {
	count int
	until time.Time
}

// maxTrackedKeys bounds memory: an attacker rotating username/IP can't grow the
// map without limit. When exceeded we fail closed instead of wiping state, so
// existing cooldowns are preserved.
const maxTrackedKeys = 10000

func newFailLimiter(max int, window time.Duration) *failLimiter {
	return &failLimiter{attempts: map[string]*attemptState{}, max: max, window: window}
}

// blocked reports whether the key is currently in cooldown.
func (l *failLimiter) blocked(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	s := l.attempts[key]
	return s != nil && time.Now().Before(s.until)
}

// fail records a failed attempt and starts a cooldown once max is reached.
// When the map is full we stop tracking new keys rather than wiping existing
// cooldowns (which an attacker could use to clear a target's throttle).
func (l *failLimiter) fail(key string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	if len(l.attempts) >= maxTrackedKeys {
		return
	}
	s := l.attempts[key]
	if s == nil {
		s = &attemptState{}
		l.attempts[key] = s
	}
	s.count++
	if s.count >= l.max {
		s.until = time.Now().Add(l.window)
		s.count = 0
	}
	if l.sync != nil {
		l.sync(key, s)
	}
}

// reset clears state for a key after a successful login.
func (l *failLimiter) reset(key string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	delete(l.attempts, key)
	if l.sync != nil {
		l.sync(key, nil)
	}
}

type ctxKey int

const clientIPKey ctxKey = 0

// WithClientIP wraps a handler so the client's IP is available downstream via
// ClientIP, so the login throttle can key on source IP without coupling to a
// specific HTTP adapter.
//
// When trustProxy is set, the IP is taken from the first X-Forwarded-For hop
// (the original client behind the reverse proxy). X-Forwarded-For is fully
// caller-controlled, so this is only honored when an admin has opted in by
// putting nadir behind a proxy - and nadir must then be reachable only by that
// proxy. When trustProxy is false the header is ignored and RemoteAddr wins.
func WithClientIP(trustProxy bool, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
		if trustProxy {
			if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
				first, _, _ := strings.Cut(xff, ",")
				ip = strings.TrimSpace(first)
			}
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey, ip)))
	})
}

// ClientIP returns the IP recorded by WithClientIP, or "" if absent.
func ClientIP(ctx context.Context) string {
	ip, _ := ctx.Value(clientIPKey).(string)
	return ip
}
