package rules

import (
	"testing"
	"time"

	"github.com/nexuscrm/sentry/internal/types"
)

// fakeHistory is a HistoryFinder backed by an in-memory map keyed by epoch.
type fakeHistory struct {
	snaps map[int64]types.Snapshot
}

func (f fakeHistory) FindClosest(_ time.Time, epoch int64) (types.Snapshot, bool) {
	s, ok := f.snaps[epoch]
	return s, ok
}

func snapshotWith(epoch int64, ts time.Time, tables []types.TableRow, outboxPending, outboxFailed int64) types.Snapshot {
	return types.Snapshot{
		SnapshotTs: ts,
		EpochID:    epoch,
		Tables:     tables,
		App: types.AppHealth{
			Outbox: types.OutboxStats{Pending: outboxPending, Failed: outboxFailed},
		},
	}
}

func tbl(name string, rows int64, src types.Source) types.TableRow {
	return types.TableRow{Name: name, RowCount: rows, Source: src}
}

// hasResult reports whether any result in results has the given rule ID and (if non-empty) entity.
func hasResult(results []types.RuleResult, ruleID, entity string) bool {
	for _, r := range results {
		if r.RuleID == ruleID && (entity == "" || r.Entity == entity) {
			return true
		}
	}
	return false
}

func resultFor(results []types.RuleResult, ruleID, entity string) (types.RuleResult, bool) {
	for _, r := range results {
		if r.RuleID == ruleID && (entity == "" || r.Entity == entity) {
			return r, true
		}
	}
	return types.RuleResult{}, false
}

// --- Row spike / drop rules (table-driven) --------------------------------

func TestDataRules_RowDelta(t *testing.T) {
	now := time.Date(2026, 4, 12, 12, 0, 0, 0, time.UTC)

	cases := []struct {
		name        string
		baseline    int64
		current     int64
		source      types.Source
		wantSpikeW  bool
		wantSpikeC  bool
		wantDropW   bool
		wantDropC   bool
	}{
		{
			name: "spike warn +51% delta 6000",
			baseline: 10000, current: 15100, source: types.SourceCountStar,
			wantSpikeW: true,
		},
		{
			name: "no spike at +49%",
			baseline: 10000, current: 14900, source: types.SourceCountStar,
		},
		{
			name: "no spike at +51% when delta < 5000",
			baseline: 2000, current: 3020, source: types.SourceCountStar, // +51%, delta 1020
		},
		{
			name: "spike crit at +101%",
			baseline: 10000, current: 20100, source: types.SourceCountStar,
			wantSpikeW: true, wantSpikeC: true,
		},
		{
			name: "spike crit by absolute delta regardless of %",
			baseline: 1_000_000, current: 1_300_000, source: types.SourceCountStar, // +30%, delta 300k
			wantSpikeC: true, // passes CRIT abs; WARN requires >50%, not fired
		},
		{
			name: "drop warn at -21%",
			baseline: 10000, current: 7900, source: types.SourceCountStar,
			wantDropW: true,
		},
		{
			name: "no drop at -19%",
			baseline: 10000, current: 8100, source: types.SourceCountStar,
		},
		{
			name: "drop crit at -51%",
			baseline: 10000, current: 4900, source: types.SourceCountStar,
			wantDropW: true, wantDropC: true,
		},
		{
			name: "estimated source tolerance: +51% suppressed",
			baseline: 10000, current: 15100, source: types.SourceInfoSchemaEst,
			// doubled threshold: now +100%; +51% does not fire
		},
		{
			name: "estimated source tolerance: +101% no longer fires CRIT",
			baseline: 10000, current: 20100, source: types.SourceInfoSchemaEst,
			// doubled: CRIT pct threshold +200%, WARN pct threshold +100%; +101% > +100% => WARN only
			wantSpikeW: true,
		},
		{
			name: "estimated source tolerance: -21% suppressed",
			baseline: 10000, current: 7900, source: types.SourceInfoSchemaEst,
			// doubled: drop WARN -40%; -21% does not fire
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			currentTbl := tbl("Account", tc.current, tc.source)
			baselineTbl := tbl("Account", tc.baseline, tc.source)
			current := snapshotWith(1, now, []types.TableRow{currentTbl}, 0, 0)
			history := fakeHistory{snaps: map[int64]types.Snapshot{
				1: snapshotWith(1, now.Add(-1*time.Hour), []types.TableRow{baselineTbl}, 0, 0),
			}}
			eng := NewEngine(AllDataRules())
			results := eng.Evaluate(current, history)

			gotSpikeW := hasResult(results, "data.row_spike_warn", "Account")
			gotSpikeC := hasResult(results, "data.row_spike_crit", "Account")
			gotDropW := hasResult(results, "data.row_drop_warn", "Account")
			gotDropC := hasResult(results, "data.row_drop_crit", "Account")

			if gotSpikeW != tc.wantSpikeW {
				t.Errorf("row_spike_warn: got %v want %v", gotSpikeW, tc.wantSpikeW)
			}
			if gotSpikeC != tc.wantSpikeC {
				t.Errorf("row_spike_crit: got %v want %v", gotSpikeC, tc.wantSpikeC)
			}
			if gotDropW != tc.wantDropW {
				t.Errorf("row_drop_warn: got %v want %v", gotDropW, tc.wantDropW)
			}
			if gotDropC != tc.wantDropC {
				t.Errorf("row_drop_crit: got %v want %v", gotDropC, tc.wantDropC)
			}
		})
	}
}

func TestDataRules_RowDelta_ColdStart(t *testing.T) {
	now := time.Date(2026, 4, 12, 12, 0, 0, 0, time.UTC)
	current := snapshotWith(1, now, []types.TableRow{tbl("Account", 99999, types.SourceCountStar)}, 0, 0)
	// empty history -> FindClosest returns false
	history := fakeHistory{snaps: map[int64]types.Snapshot{}}
	eng := NewEngine(AllDataRules())
	results := eng.Evaluate(current, history)
	for _, r := range results {
		switch r.RuleID {
		case "data.row_spike_warn", "data.row_spike_crit", "data.row_drop_warn", "data.row_drop_crit", "data.outbox_failed_surge":
			t.Errorf("cold start should not yield delta rule %s, got %+v", r.RuleID, r)
		}
	}
}

// --- Outbox rules ----------------------------------------------------------

func TestDataRules_OutboxBacklog(t *testing.T) {
	now := time.Date(2026, 4, 12, 12, 0, 0, 0, time.UTC)
	cases := []struct {
		name     string
		pending  int64
		wantWarn bool
		wantCrit bool
	}{
		{"below warn", 49999, false, false},
		{"at warn threshold boundary 50000", 50000, false, false}, // strictly >
		{"warn 50001", 50001, true, false},
		{"at crit", 200000, true, false},
		{"crit 200001", 200001, true, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			current := snapshotWith(1, now, nil, tc.pending, 0)
			eng := NewEngine(AllDataRules())
			results := eng.Evaluate(current, fakeHistory{})

			gotW := hasResult(results, "data.outbox_backlog_warn", "")
			gotC := hasResult(results, "data.outbox_backlog_crit", "")
			if gotW != tc.wantWarn {
				t.Errorf("backlog_warn: got %v want %v", gotW, tc.wantWarn)
			}
			if gotC != tc.wantCrit {
				t.Errorf("backlog_crit: got %v want %v", gotC, tc.wantCrit)
			}
		})
	}
}

func TestDataRules_OutboxFailedSurge(t *testing.T) {
	now := time.Date(2026, 4, 12, 12, 0, 0, 0, time.UTC)
	cases := []struct {
		name         string
		baselineFail int64
		currentFail  int64
		want         bool
	}{
		{"surge 501", 100, 601, true},
		{"surge exactly 500 (not strictly >)", 100, 600, false},
		{"surge 499", 100, 599, false},
		{"negative (reset) does not fire", 1000, 50, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			current := snapshotWith(1, now, nil, 0, tc.currentFail)
			history := fakeHistory{snaps: map[int64]types.Snapshot{
				1: snapshotWith(1, now.Add(-1*time.Hour), nil, 0, tc.baselineFail),
			}}
			eng := NewEngine(AllDataRules())
			results := eng.Evaluate(current, history)
			got := hasResult(results, "data.outbox_failed_surge", "")
			if got != tc.want {
				t.Errorf("failed_surge: got %v want %v", got, tc.want)
			}
			if tc.want {
				r, _ := resultFor(results, "data.outbox_failed_surge", "")
				if r.Level != "WARN" {
					t.Errorf("expected WARN, got %s", r.Level)
				}
			}
		})
	}
}

func TestDataRules_AllDataRules_Deterministic(t *testing.T) {
	a := AllDataRules()
	b := AllDataRules()
	if len(a) != len(b) {
		t.Fatalf("length mismatch")
	}
	for i := range a {
		if a[i].ID() != b[i].ID() {
			t.Errorf("order differs at %d: %s vs %s", i, a[i].ID(), b[i].ID())
		}
	}
	// ensure sorted by ID
	for i := 1; i < len(a); i++ {
		if a[i-1].ID() > a[i].ID() {
			t.Errorf("not sorted: %s before %s", a[i-1].ID(), a[i].ID())
		}
	}
}
