package main

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

// httpBindIsNonLoopback reports whether an http listen address (e.g. ":8080",
// "0.0.0.0:8080", "127.0.0.1:8080", "[::1]:8080") would accept connections on
// an interface other than loopback. A bare ":port" or "0.0.0.0"/"::" binds all
// interfaces → non-loopback. An empty/unparseable host is treated as
// all-interfaces (the conservative choice for a security warning).
func httpBindIsNonLoopback(addr string) bool {
	host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
	if err != nil {
		// No port separator — treat the whole thing as the host.
		host = strings.TrimSpace(addr)
	}
	if host == "" || host == "0.0.0.0" || host == "::" {
		return true // wildcard bind — all interfaces
	}
	ip := net.ParseIP(host)
	if ip == nil {
		// A hostname we can't classify cheaply; assume exposed.
		return true
	}
	return !ip.IsLoopback()
}

// loginLimiter is a dependency-free, in-memory brute-force guard for the
// login endpoint. It tracks failed attempts keyed independently by client IP
// and by target email, so an attacker cannot dodge the IP limit by rotating
// emails, nor dodge the email limit by rotating IPs.
//
// After failLimit failures inside the sliding failWindow, the key is locked
// out for lockout. A successful login (recorded via Reset) clears the key's
// counter immediately, so a legitimate user is never penalized for a stale
// failure count.
type loginLimiter struct {
	mu         sync.Mutex
	entries    map[string]*loginAttempt
	failLimit  int
	failWindow time.Duration
	lockout    time.Duration
	now        func() time.Time // injectable for tests
}

type loginAttempt struct {
	fails       int
	first       time.Time // start of the current counting window
	lockedUntil time.Time
}

func newLoginLimiter(failLimit int, failWindow, lockout time.Duration) *loginLimiter {
	return &loginLimiter{
		entries:    make(map[string]*loginAttempt),
		failLimit:  failLimit,
		failWindow: failWindow,
		lockout:    lockout,
		now:        time.Now,
	}
}

// keysFor returns the two limiter keys (IP + email) for a login attempt.
// Empty components are skipped so we never bucket everything under "ip:".
func keysFor(ip, email string) []string {
	var ks []string
	if ip != "" {
		ks = append(ks, "ip:"+ip)
	}
	if email != "" {
		ks = append(ks, "email:"+email)
	}
	return ks
}

// Allowed reports whether a login attempt for this ip/email may proceed.
// If locked out, it returns false and the Retry-After duration the caller
// should surface to the client.
func (l *loginLimiter) Allowed(ip, email string) (bool, time.Duration) {
	if l == nil {
		return true, 0
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	now := l.now()
	var maxRetry time.Duration
	blocked := false
	for _, k := range keysFor(ip, email) {
		e := l.entries[k]
		if e == nil {
			continue
		}
		if now.Before(e.lockedUntil) {
			blocked = true
			if d := e.lockedUntil.Sub(now); d > maxRetry {
				maxRetry = d
			}
		}
	}
	if blocked {
		return false, maxRetry
	}
	return true, 0
}

// RecordFailure increments the failure counters for ip + email and applies a
// lockout once the threshold is crossed inside the window.
func (l *loginLimiter) RecordFailure(ip, email string) {
	if l == nil {
		return
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	now := l.now()
	for _, k := range keysFor(ip, email) {
		e := l.entries[k]
		if e == nil {
			e = &loginAttempt{first: now}
			l.entries[k] = e
		}
		// Reset the window if the previous one has elapsed (and we're not
		// currently locked out).
		if now.After(e.first.Add(l.failWindow)) && now.After(e.lockedUntil) {
			e.fails = 0
			e.first = now
		}
		e.fails++
		if e.fails >= l.failLimit {
			e.lockedUntil = now.Add(l.lockout)
		}
	}
}

// Reset clears the counters for a successful login so the user isn't carrying
// a stale failure count.
func (l *loginLimiter) Reset(ip, email string) {
	if l == nil {
		return
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	for _, k := range keysFor(ip, email) {
		delete(l.entries, k)
	}
}

// cleanup drops entries whose window has elapsed and whose lockout has
// expired, to bound memory under a sustained scan. Callers may run this
// periodically; it is safe to never call (map stays bounded by distinct
// IP/email pairs seen within the retention horizon).
func (l *loginLimiter) cleanup() {
	if l == nil {
		return
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	now := l.now()
	for k, e := range l.entries {
		if now.After(e.lockedUntil) && now.After(e.first.Add(l.failWindow)) {
			delete(l.entries, k)
		}
	}
}

// normalizeIP strips the port from an IP:port RemoteAddr (or X-Forwarded-For
// entry), returning a stable per-host key. Falls back to the raw string if it
// can't be parsed.
func normalizeIP(addr string) string {
	if addr == "" {
		return ""
	}
	if host, _, err := net.SplitHostPort(addr); err == nil {
		return host
	}
	return addr
}
