package main

import (
	"testing"
	"time"
)

func newTestLimiter() (*loginLimiter, *time.Time) {
	base := time.Unix(1_700_000_000, 0)
	cur := base
	l := newLoginLimiter(3, 10*time.Minute, 15*time.Minute)
	l.now = func() time.Time { return cur }
	return l, &cur
}

func TestLimiter_AllowsUntilThreshold(t *testing.T) {
	l, _ := newTestLimiter()
	ip, email := "1.2.3.4", "a@b.com"
	// failLimit = 3. First 2 failures still leave the account allowed.
	for i := 0; i < 2; i++ {
		if ok, _ := l.Allowed(ip, email); !ok {
			t.Fatalf("attempt %d should be allowed", i)
		}
		l.RecordFailure(ip, email)
	}
	// 3rd failure crosses the threshold → now locked.
	l.RecordFailure(ip, email)
	ok, retry := l.Allowed(ip, email)
	if ok {
		t.Fatal("expected lockout after threshold")
	}
	if retry <= 0 || retry > 15*time.Minute {
		t.Fatalf("unexpected retry-after: %v", retry)
	}
}

func TestLimiter_ResetOnSuccess(t *testing.T) {
	l, _ := newTestLimiter()
	ip, email := "1.2.3.4", "a@b.com"
	l.RecordFailure(ip, email)
	l.RecordFailure(ip, email)
	// A success clears the counter; further failures start from zero.
	l.Reset(ip, email)
	l.RecordFailure(ip, email)
	l.RecordFailure(ip, email)
	if ok, _ := l.Allowed(ip, email); !ok {
		t.Fatal("counter should have reset on success; must still be allowed")
	}
}

func TestLimiter_LockoutExpires(t *testing.T) {
	l, cur := newTestLimiter()
	ip, email := "1.2.3.4", "a@b.com"
	for i := 0; i < 3; i++ {
		l.RecordFailure(ip, email)
	}
	if ok, _ := l.Allowed(ip, email); ok {
		t.Fatal("should be locked")
	}
	// Advance past the lockout window.
	*cur = cur.Add(16 * time.Minute)
	if ok, _ := l.Allowed(ip, email); !ok {
		t.Fatal("lockout should have expired")
	}
}

func TestLimiter_WindowRollover(t *testing.T) {
	l, cur := newTestLimiter()
	ip, email := "1.2.3.4", "a@b.com"
	// 2 fails, then let the fail window elapse — the count should reset so
	// two more fails don't trip the lockout.
	l.RecordFailure(ip, email)
	l.RecordFailure(ip, email)
	*cur = cur.Add(11 * time.Minute) // past the 10-min window
	l.RecordFailure(ip, email)
	if ok, _ := l.Allowed(ip, email); !ok {
		t.Fatal("window should have rolled over; not locked yet")
	}
}

func TestLimiter_EmailKeyAcrossIPs(t *testing.T) {
	l, _ := newTestLimiter()
	email := "victim@b.com"
	// Attacker rotates IP each attempt but hammers one email. The email key
	// must still trip the lockout independently of IP.
	l.RecordFailure("1.1.1.1", email)
	l.RecordFailure("2.2.2.2", email)
	l.RecordFailure("3.3.3.3", email)
	if ok, _ := l.Allowed("4.4.4.4", email); ok {
		t.Fatal("email-keyed lockout should hold across differing IPs")
	}
}

func TestLimiter_IPKeyAcrossEmails(t *testing.T) {
	l, _ := newTestLimiter()
	ip := "9.9.9.9"
	// Attacker rotates email but from one IP. The IP key trips the lockout.
	l.RecordFailure(ip, "a@b.com")
	l.RecordFailure(ip, "c@d.com")
	l.RecordFailure(ip, "e@f.com")
	if ok, _ := l.Allowed(ip, "z@z.com"); ok {
		t.Fatal("IP-keyed lockout should hold across differing emails")
	}
}

func TestLimiter_NilSafe(t *testing.T) {
	var l *loginLimiter
	if ok, _ := l.Allowed("1.2.3.4", "a@b.com"); !ok {
		t.Fatal("nil limiter must allow")
	}
	l.RecordFailure("1.2.3.4", "a@b.com") // must not panic
	l.Reset("1.2.3.4", "a@b.com")         // must not panic
}

func TestNormalizeIP(t *testing.T) {
	cases := map[string]string{
		"1.2.3.4:5678": "1.2.3.4",
		"1.2.3.4":      "1.2.3.4",
		"[::1]:8080":   "::1",
		"":             "",
	}
	for in, want := range cases {
		if got := normalizeIP(in); got != want {
			t.Errorf("normalizeIP(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestHTTPBindIsNonLoopback(t *testing.T) {
	cases := map[string]bool{
		":8080":            true,
		"0.0.0.0:8080":     true,
		"::":               true,
		"127.0.0.1:8080":   false,
		"[::1]:8080":       false,
		"localhost:8080":   true, // hostname → conservatively exposed
		"192.168.1.5:8080": true,
	}
	for addr, want := range cases {
		if got := httpBindIsNonLoopback(addr); got != want {
			t.Errorf("httpBindIsNonLoopback(%q) = %v, want %v", addr, got, want)
		}
	}
}
