package logscanner

import (
	"regexp"
	"testing"
	"time"
)

func mustRegexp(t *testing.T, p string) *regexp.Regexp {
	t.Helper()
	re, err := regexp.Compile(p)
	if err != nil {
		t.Fatalf("compile %q: %v", p, err)
	}
	return re
}

func newRule(t *testing.T, id int64, pattern string, opts ...func(*Rule)) Rule {
	r := Rule{
		ID:              id,
		Name:            "rule" + pattern,
		Pattern:         mustRegexp(t, pattern),
		Severity:        "warn",
		Streams:         "all",
		CooldownSeconds: 0,
	}
	for _, o := range opts {
		o(&r)
	}
	return r
}

func TestEngineMatch_BasicHit(t *testing.T) {
	e := NewEngine()
	rules := []Rule{newRule(t, 1, `panic`)}
	hits := e.Match("c1", "w1", "stderr", "fatal panic in worker", rules)
	if len(hits) != 1 {
		t.Fatalf("want 1 hit, got %d", len(hits))
	}
	if hits[0].Rule.ID != 1 {
		t.Errorf("rule id mismatch: %d", hits[0].Rule.ID)
	}
}

func TestEngineMatch_StreamFilter(t *testing.T) {
	e := NewEngine()
	rules := []Rule{newRule(t, 1, `boom`, func(r *Rule) { r.Streams = "stderr" })}
	if len(e.Match("c", "w", "stdout", "boom there", rules)) != 0 {
		t.Error("stdout line should not match stderr-only rule")
	}
	if len(e.Match("c", "w", "stderr", "boom there", rules)) != 1 {
		t.Error("stderr line should match stderr-only rule")
	}
}

func TestEngineMatch_NoMatchNoHit(t *testing.T) {
	e := NewEngine()
	rules := []Rule{newRule(t, 1, `panic`)}
	if h := e.Match("c", "w", "stdout", "all good", rules); len(h) != 0 {
		t.Errorf("expected no hit, got %d", len(h))
	}
}

func TestEngineMatch_Cooldown(t *testing.T) {
	now := time.Now()
	clock := now
	e := NewEngine(WithNow(func() time.Time { return clock }))
	rules := []Rule{newRule(t, 1, `bad`, func(r *Rule) { r.CooldownSeconds = 30 })}

	if len(e.Match("c", "w", "stdout", "bad event", rules)) != 1 {
		t.Fatal("first fire expected")
	}
	clock = now.Add(10 * time.Second)
	if h := e.Match("c", "w", "stdout", "bad event 2", rules); len(h) != 0 {
		t.Errorf("cooled-down rule fired: %+v", h)
	}
	clock = now.Add(31 * time.Second)
	if len(e.Match("c", "w", "stdout", "bad event 3", rules)) != 1 {
		t.Error("cooldown expired but rule did not fire")
	}
}

func TestEngineMatch_PerContainerCooldownIndependent(t *testing.T) {
	// Same rule firing on two different containers should not
	// share cooldown — operators expect each container's alerts
	// to be independent.
	now := time.Now()
	clock := now
	e := NewEngine(WithNow(func() time.Time { return clock }))
	rules := []Rule{newRule(t, 1, `bad`, func(r *Rule) { r.CooldownSeconds = 30 })}

	e.Match("c1", "w", "stdout", "bad on one", rules)
	if len(e.Match("c2", "w", "stdout", "bad on two", rules)) != 1 {
		t.Error("second container should fire independently")
	}
}

func TestEngineMatch_TokenBucket(t *testing.T) {
	now := time.Now()
	clock := now
	e := NewEngine(WithNow(func() time.Time { return clock }), WithBucket(3, time.Minute))
	rules := []Rule{newRule(t, 1, `noisy`)}
	for i := 0; i < 3; i++ {
		if len(e.Match("c", "w", "stdout", "noisy "+string(rune('a'+i)), rules)) != 1 {
			t.Errorf("hit %d should fire", i)
		}
	}
	// 4th within window should be dropped.
	if h := e.Match("c", "w", "stdout", "noisy d", rules); len(h) != 0 {
		t.Errorf("4th hit should be rate-limited, got %d", len(h))
	}
	// After the window the bucket refills.
	clock = now.Add(time.Minute + time.Second)
	if len(e.Match("c", "w", "stdout", "noisy e", rules)) != 1 {
		t.Error("bucket should have refilled after window")
	}
}

func TestEngineForget_DropsState(t *testing.T) {
	e := NewEngine()
	rules := []Rule{newRule(t, 1, `bad`, func(r *Rule) { r.CooldownSeconds = 999 })}
	e.Match("c", "w", "stdout", "bad once", rules)
	e.Forget("c")
	// After Forget, the same rule on the same container should
	// fire again immediately — cooldown state was cleared.
	if len(e.Match("c", "w", "stdout", "bad again", rules)) != 1 {
		t.Error("Forget should drop cooldown state")
	}
}

func TestEngineStats_CooldownCounter(t *testing.T) {
	// Two matches inside the cooldown window should drop once and
	// increment DroppedByCooldown by one. Bucket is generous so it
	// never participates.
	now := time.Now()
	clock := now
	e := NewEngine(
		WithNow(func() time.Time { return clock }),
		WithBucket(100, time.Hour),
	)
	rule := newRule(t, 1, `bad`, func(r *Rule) { r.CooldownSeconds = 60 })

	if len(e.Match("c", "w", "stdout", "bad a", []Rule{rule})) != 1 {
		t.Fatal("first fire expected")
	}
	if len(e.Match("c", "w", "stdout", "bad b", []Rule{rule})) != 0 {
		t.Fatal("second fire inside cooldown should drop")
	}
	stats := e.Stats()
	if stats.DroppedByCooldown != 1 {
		t.Errorf("DroppedByCooldown = %d, want 1", stats.DroppedByCooldown)
	}
	if stats.DroppedByBucket != 0 {
		t.Errorf("DroppedByBucket = %d, want 0 (bucket should not have fired)",
			stats.DroppedByBucket)
	}
}

func TestEngineStats_BucketCounter(t *testing.T) {
	// Drain a small bucket without any cooldown so every drop
	// after the first is bucket-attributable.
	now := time.Now()
	clock := now
	e := NewEngine(
		WithNow(func() time.Time { return clock }),
		WithBucket(2, time.Hour),
	)
	rule := newRule(t, 1, `bad`) // cooldown=0

	// 2 fires consume both tokens.
	for i := 0; i < 2; i++ {
		if len(e.Match("c", "w", "stdout", "bad", []Rule{rule})) != 1 {
			t.Fatalf("fire %d expected to succeed", i)
		}
	}
	// 3rd and 4th fires hit an empty bucket.
	for i := 0; i < 2; i++ {
		if len(e.Match("c", "w", "stdout", "bad", []Rule{rule})) != 0 {
			t.Fatalf("fire %d expected to be bucket-dropped", i+2)
		}
	}
	stats := e.Stats()
	if stats.DroppedByBucket != 2 {
		t.Errorf("DroppedByBucket = %d, want 2", stats.DroppedByBucket)
	}
	if stats.DroppedByCooldown != 0 {
		t.Errorf("DroppedByCooldown = %d, want 0 (cooldown should not have fired)",
			stats.DroppedByCooldown)
	}
}

func TestEngineStats_NoDropsWhenAllowed(t *testing.T) {
	e := NewEngine(WithBucket(100, time.Minute))
	rule := newRule(t, 1, `x`)
	for i := 0; i < 5; i++ {
		e.Match("c", "w", "stdout", "x", []Rule{rule})
	}
	stats := e.Stats()
	if stats.DroppedByBucket != 0 || stats.DroppedByCooldown != 0 {
		t.Errorf("expected zero drops, got %+v", stats)
	}
}

func TestStreamMatches(t *testing.T) {
	cases := []struct {
		rule, line string
		want       bool
	}{
		{"all", "stdout", true},
		{"all", "stderr", true},
		{"", "stdout", true},
		{"stdout", "stdout", true},
		{"stdout", "stderr", false},
		{"stderr", "stderr", true},
		{"stderr", "stdout", false},
	}
	for _, c := range cases {
		got := streamMatches(c.rule, c.line)
		if got != c.want {
			t.Errorf("streamMatches(%q,%q) = %v want %v", c.rule, c.line, got, c.want)
		}
	}
}
