// login_throttle.go throttles login brute-force by counting consecutive failed
// attempts per (ip, email) key in a fixed time window — the same fixed-window
// token-bucket algorithm proven in internal/mcp.RateLimiter, kept local here to
// avoid an import cycle (user must not depend on mcp). On too many failures
// within the window the next attempt is rejected with retry-after BEFORE any
// password hash is computed, so an attacker cannot keep spending CPU on bcrypt.
//
// Keying by ip+email (not ip alone) means one attacker cannot lock a victim out
// by hammering the victim's email from arbitrary IPs, and a shared NAT IP cannot
// lock every user behind it; it does mean a botnet rotating IPs is not fully
// stopped — that is the documented limit of a per-(ip,email) counter.
package user

import (
	"strings"
	"sync"
	"time"
)

// LoginThrottleConfig controls the failure window. MaxFailures failed attempts
// within Window blocks further attempts until the window rolls over.
type LoginThrottleConfig struct {
	Enabled     bool
	MaxFailures int
	Window      time.Duration
}

// attemptBucket is one (ip,email) key's failure counter for the current window.
type attemptBucket struct {
	count    int
	windowAt time.Time
}

// LoginThrottle is a concurrency-safe per-key fixed-window failure limiter.
type LoginThrottle struct {
	cfg LoginThrottleConfig
	mu  sync.Mutex
	m   map[string]*attemptBucket
	now func() time.Time // injectable for tests
}

// NewLoginThrottle builds a throttle. now=nil uses time.Now. A disabled config
// (Enabled=false or MaxFailures<=0) yields a throttle that always allows.
func NewLoginThrottle(cfg LoginThrottleConfig, now func() time.Time) *LoginThrottle {
	if now == nil {
		now = time.Now
	}
	if cfg.Window <= 0 {
		cfg.Window = 15 * time.Minute
	}
	return &LoginThrottle{cfg: cfg, m: map[string]*attemptBucket{}, now: now}
}

// key normalizes (ip, email) into a single bucket key. Email is lowercased so
// case variants share one counter; IP is taken as-is.
func throttleKey(ip, email string) string {
	return ip + ":" + strings.ToLower(strings.TrimSpace(email))
}

// disabled reports whether the throttle is a no-op.
func (t *LoginThrottle) disabled() bool {
	return t == nil || !t.cfg.Enabled || t.cfg.MaxFailures <= 0
}

// Allow reports whether a login attempt for (ip,email) may proceed. When
// blocked it returns the duration until the current window rolls over. Allow
// does NOT itself record an attempt — call RecordFailure on bad credentials and
// Reset on success.
func (t *LoginThrottle) Allow(ip, email string) (bool, time.Duration) {
	if t.disabled() {
		return true, 0
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	now := t.now()
	b := t.m[throttleKey(ip, email)]
	if b == nil {
		return true, 0
	}
	if now.Sub(b.windowAt) >= t.cfg.Window {
		// Window expired: stale counter, allow (RecordFailure will reset it).
		return true, 0
	}
	if b.count >= t.cfg.MaxFailures {
		retry := t.cfg.Window - now.Sub(b.windowAt)
		if retry < 0 {
			retry = 0
		}
		return false, retry
	}
	return true, 0
}

// RecordFailure increments the failure counter for (ip,email), starting a new
// window if the previous one has expired.
func (t *LoginThrottle) RecordFailure(ip, email string) {
	if t.disabled() {
		return
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	now := t.now()
	k := throttleKey(ip, email)
	b := t.m[k]
	if b == nil || now.Sub(b.windowAt) >= t.cfg.Window {
		b = &attemptBucket{windowAt: now}
		t.m[k] = b
	}
	b.count++
}

// Reset clears the failure counter for (ip,email) after a successful login.
func (t *LoginThrottle) Reset(ip, email string) {
	if t.disabled() {
		return
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	delete(t.m, throttleKey(ip, email))
}
