package ratelimit

import (
	"testing"
	"time"
)

// banlistAt builds an enabled banlist whose clock the test drives through clk.
func banlistAt(clk *time.Time, cfg BanConfig) *Banlist {
	bl := NewBanlist(cfg)
	bl.now = func() time.Time { return *clk }
	return bl
}

func enabledCfg() BanConfig {
	return BanConfig{Enabled: true, Threshold: 3, Window: time.Minute, Duration: 15 * time.Minute}
}

func TestBanlistStrikeThreshold(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	if bl.Strike("1.2.3.4") {
		t.Fatal("first strike must not ban")
	}
	if bl.Strike("1.2.3.4") {
		t.Fatal("second strike must not ban")
	}
	if bl.Banned("1.2.3.4") {
		t.Fatal("must not be banned before the third strike")
	}
	if !bl.Strike("1.2.3.4") {
		t.Fatal("third strike within the window must ban")
	}
	if !bl.Banned("1.2.3.4") {
		t.Fatal("must be banned after the threshold strike")
	}
}

func TestBanlistStrikeWindowResets(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	// Strikes spaced wider than the window never accumulate to a ban.
	for i := range 5 {
		if bl.Strike("9.9.9.9") {
			t.Fatalf("strike %d should not ban: each falls outside the previous window", i)
		}
		clk = clk.Add(2 * time.Minute)
	}
	if bl.Banned("9.9.9.9") {
		t.Fatal("strikes outside the rolling window must not ban")
	}
}

func TestBanlistBanExpires(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	bl.BanNow("5.5.5.5", ReasonRejections)
	if !bl.Banned("5.5.5.5") {
		t.Fatal("must be banned right after BanNow")
	}
	clk = clk.Add(15*time.Minute + time.Second)
	if bl.Banned("5.5.5.5") {
		t.Fatal("ban must lapse after its duration")
	}
}

func TestBanlistReasonDurations(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	bl.BanNow("a", ReasonTripwire)   // 1h
	bl.BanNow("b", ReasonHoneytoken) // 24h
	clk = clk.Add(90 * time.Minute)
	if bl.Banned("a") {
		t.Fatal("tripwire ban (1h) must have lapsed after 90m")
	}
	if !bl.Banned("b") {
		t.Fatal("honeytoken ban (24h) must still hold after 90m")
	}
}

func TestBanlistUnban(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	bl.BanNow("7.7.7.7", ReasonTripwire)
	bl.Unban("7.7.7.7")
	if bl.Banned("7.7.7.7") {
		t.Fatal("Unban must clear the ban")
	}
}

func TestBanlistActiveSnapshot(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	bl := banlistAt(&clk, enabledCfg())

	bl.BanNow("a", ReasonTripwire)
	bl.BanNow("b", ReasonHoneytoken)
	active := bl.Active()
	if len(active) != 2 {
		t.Fatalf("Active = %d bans, want 2", len(active))
	}
	byIP := map[string]Ban{}
	for _, b := range active {
		byIP[b.IP] = b
	}
	if byIP["a"].Reason != ReasonTripwire || byIP["b"].Reason != ReasonHoneytoken {
		t.Fatalf("Active reasons = %+v", byIP)
	}
	if !byIP["a"].Expires.After(byIP["a"].Since) {
		t.Fatal("Expires must be after Since")
	}

	clk = clk.Add(2 * time.Hour) // tripwire (1h) lapses, honeytoken (24h) holds
	if got := len(bl.Active()); got != 1 {
		t.Fatalf("Active after 2h = %d, want 1 (only honeytoken)", got)
	}
}

func TestBanlistDisabledIsInert(t *testing.T) {
	clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
	cfg := enabledCfg()
	cfg.Enabled = false
	bl := banlistAt(&clk, cfg)

	for range 10 {
		if bl.Strike("1.1.1.1") {
			t.Fatal("disabled banlist must never ban via Strike")
		}
	}
	if bl.BanNow("2.2.2.2", ReasonHoneytoken) {
		t.Fatal("disabled banlist must never ban via BanNow")
	}
	if bl.Banned("1.1.1.1") || bl.Banned("2.2.2.2") {
		t.Fatal("disabled banlist must report nothing banned")
	}
	if len(bl.Active()) != 0 {
		t.Fatal("disabled banlist must expose no active bans")
	}
}
