package cycle

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"testing"
	"time"

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

// --- fakes ------------------------------------------------------------------

type fakeDiscoverer struct {
	tables []string
	err    error
}

func (f *fakeDiscoverer) DiscoverTables(ctx context.Context) ([]string, error) {
	return f.tables, f.err
}

type fakeCollector struct {
	rows     []types.TableRow
	err      error
	gotSlow  map[string]bool
	gotCalls int
}

func (f *fakeCollector) Snapshot(ctx context.Context, tables []string, slow map[string]bool) ([]types.TableRow, error) {
	f.gotCalls++
	// Capture a copy so tests can observe slowTables passed in.
	snap := make(map[string]bool, len(slow))
	for k, v := range slow {
		snap[k] = v
	}
	f.gotSlow = snap
	return f.rows, f.err
}

type fakeScraper struct {
	app *types.AppHealth
	err error
}

func (f *fakeScraper) Scrape(ctx context.Context) (*types.AppHealth, error) {
	return f.app, f.err
}

type fakeEpoch struct{ val int64 }

func (f *fakeEpoch) EpochAt(at time.Time) int64 { return f.val }

type fakeStore struct {
	added []types.Snapshot
}

func (f *fakeStore) Add(s types.Snapshot) { f.added = append(f.added, s) }
func (f *fakeStore) FindClosest(target time.Time, epoch int64) (types.Snapshot, bool) {
	return types.Snapshot{}, false
}

type fakePersister struct {
	calls int
	err   error
}

func (f *fakePersister) PutSnapshot(ctx context.Context, snap types.Snapshot) error {
	f.calls++
	return f.err
}

type fakeEngine struct {
	results []types.RuleResult
}

func (f *fakeEngine) Evaluate(current types.Snapshot, history HistoryFinder) []types.RuleResult {
	return f.results
}

type fakeSuppressor struct {
	passthrough bool
	filtered    []types.RuleResult
}

func (f *fakeSuppressor) Filter(ts time.Time, rs []types.RuleResult) []types.RuleResult {
	if f.passthrough {
		return rs
	}
	return f.filtered
}

type fakeLLM struct {
	text string
	err  error
}

func (f *fakeLLM) Translate(ctx context.Context, r types.RuleResult) (string, error) {
	return f.text, f.err
}

type fakeNotifier struct {
	mu   sync.Mutex
	sent []string
	err  error
}

func (f *fakeNotifier) SendText(ctx context.Context, text string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.sent = append(f.sent, text)
	return f.err
}

type fakeLogger struct {
	mu   sync.Mutex
	msgs []string
}

func (f *fakeLogger) Printf(format string, args ...any) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.msgs = append(f.msgs, fmt.Sprintf(format, args...))
}

func templateFn(r types.RuleResult) string {
	return "TEMPLATE-" + r.RuleID
}

// --- helpers ----------------------------------------------------------------

func baseCycle() (*Cycle, *fakeDiscoverer, *fakeCollector, *fakeScraper, *fakeStore, *fakePersister, *fakeEngine, *fakeSuppressor, *fakeLLM, *fakeNotifier, *fakeLogger) {
	disc := &fakeDiscoverer{tables: []string{"Account", "Lead"}}
	coll := &fakeCollector{rows: []types.TableRow{{Name: "Account", RowCount: 10, Source: types.SourceCountStar}}}
	scr := &fakeScraper{app: &types.AppHealth{Version: "v1"}}
	store := &fakeStore{}
	persist := &fakePersister{}
	eng := &fakeEngine{results: []types.RuleResult{{RuleID: "D-001", Level: "WARN", Breached: true}}}
	sup := &fakeSuppressor{passthrough: true}
	llm := &fakeLLM{text: "LLM-rendered"}
	not := &fakeNotifier{}
	log := &fakeLogger{}

	c := NewCycle(Cycle{
		Version:    "test",
		Discoverer: disc,
		Collector:  coll,
		Scraper:    scr,
		Epoch:      &fakeEpoch{val: 42},
		Store:      store,
		Persister:  persist,
		Engine:     eng,
		Suppressor: sup,
		LLM:        llm,
		Template:   templateFn,
		Notifier:   not,
		Logger:     log,
	})
	return c, disc, coll, scr, store, persist, eng, sup, llm, not, log
}

// --- tests ------------------------------------------------------------------

func TestRun_HappyPath_LLMTranslatesAndNotifies(t *testing.T) {
	c, _, _, _, store, persist, _, _, _, not, _ := baseCycle()

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(store.added) != 1 {
		t.Fatalf("expected 1 snapshot added, got %d", len(store.added))
	}
	if store.added[0].EpochID != 42 {
		t.Errorf("epoch = %d, want 42", store.added[0].EpochID)
	}
	if persist.calls != 1 {
		t.Errorf("persist calls = %d, want 1", persist.calls)
	}
	if len(not.sent) != 1 || not.sent[0] != "LLM-rendered" {
		t.Errorf("notifier sent = %v, want [LLM-rendered]", not.sent)
	}
}

func TestRun_LLMFails_FallsBackToTemplate(t *testing.T) {
	c, _, _, _, _, _, _, _, llm, not, _ := baseCycle()
	llm.err = errors.New("boom")
	llm.text = ""

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 || not.sent[0] != "TEMPLATE-D-001" {
		t.Errorf("notifier sent = %v, want [TEMPLATE-D-001]", not.sent)
	}
}

func TestRun_LLMNil_UsesTemplateDirectly(t *testing.T) {
	c, _, _, _, _, _, _, _, _, not, _ := baseCycle()
	c.LLM = nil

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 || not.sent[0] != "TEMPLATE-D-001" {
		t.Errorf("notifier sent = %v, want [TEMPLATE-D-001]", not.sent)
	}
}

func TestRun_NotifierFails_CycleContinues(t *testing.T) {
	c, _, _, _, _, _, _, _, _, not, log := baseCycle()
	not.err = errors.New("lark down")

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	// Error should be logged but not returned.
	found := false
	for _, m := range log.msgs {
		if contains(m, "notify failed") {
			found = true
			break
		}
	}
	if !found {
		t.Errorf("expected notify failure to be logged, got msgs=%v", log.msgs)
	}
}

func TestRun_AppScrapeFails_SoftFail(t *testing.T) {
	c, _, _, scr, store, persist, _, _, _, _, _ := baseCycle()
	scr.app = nil
	scr.err = errors.New("500")

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if persist.calls != 1 {
		t.Errorf("persist calls = %d, want 1", persist.calls)
	}
	if len(store.added) != 1 {
		t.Fatalf("expected snapshot added")
	}
	if store.added[0].App.Version != "" {
		t.Errorf("expected empty App on scrape fail, got %+v", store.added[0].App)
	}
}

func TestRun_DiscovererFails_Returns(t *testing.T) {
	c, disc, coll, _, store, persist, _, _, _, not, _ := baseCycle()
	disc.err = errors.New("no db")
	disc.tables = nil

	if err := c.Run(context.Background()); err == nil {
		t.Fatal("expected error from Run")
	}
	if coll.gotCalls != 0 {
		t.Errorf("collector should not be called, gotCalls=%d", coll.gotCalls)
	}
	if len(store.added) != 0 || persist.calls != 0 || len(not.sent) != 0 {
		t.Errorf("no downstream work should happen on discover failure")
	}
}

func TestRun_PersistFails_SoftFail(t *testing.T) {
	c, _, _, _, _, persist, _, _, _, not, log := baseCycle()
	persist.err = errors.New("s3 down")

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 {
		t.Errorf("notifications should still fire, got %d", len(not.sent))
	}
	found := false
	for _, m := range log.msgs {
		if contains(m, "persist failed") {
			found = true
		}
	}
	if !found {
		t.Errorf("expected persist failure log, got %v", log.msgs)
	}
}

func TestRun_SuppressionFiltersAll_NoNotifications(t *testing.T) {
	c, _, _, _, _, _, _, sup, _, not, _ := baseCycle()
	sup.passthrough = false
	sup.filtered = nil

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 0 {
		t.Errorf("expected 0 notifications, got %d", len(not.sent))
	}
}

func TestRun_SlowTableDegradation_FeedsBackIntoNextCycle(t *testing.T) {
	c, _, coll, _, _, _, _, _, _, _, _ := baseCycle()
	coll.rows = []types.TableRow{
		{Name: "HugeTable", RowCount: 5000000, Source: types.SourceInfoSchemaEst},
		{Name: "Account", RowCount: 10, Source: types.SourceCountStar},
	}

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("first Run: %v", err)
	}
	if !c.slowTables["HugeTable"] {
		t.Errorf("HugeTable should be tracked as slow after degradation")
	}
	if c.slowTables["Account"] {
		t.Errorf("Account should NOT be slow")
	}

	// Second cycle: the collector should receive HugeTable in its slow set.
	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("second Run: %v", err)
	}
	if !coll.gotSlow["HugeTable"] {
		t.Errorf("second cycle collector did not receive slowTables[HugeTable]=true, got %v", coll.gotSlow)
	}
}

func TestRun_SnapshotCollectionFails_Returns(t *testing.T) {
	c, _, coll, _, store, persist, _, _, _, not, _ := baseCycle()
	coll.err = errors.New("query error")
	coll.rows = nil

	if err := c.Run(context.Background()); err == nil {
		t.Fatal("expected error from snapshot collection")
	}
	if len(store.added) != 0 || persist.calls != 0 || len(not.sent) != 0 {
		t.Errorf("no downstream work should happen on collection failure")
	}
}

func TestRun_NotifyOnlyCRIT_SkipsWarn(t *testing.T) {
	// Default baseCycle has a WARN result. With NotifyOnlyCRIT the
	// notifier should receive zero sends, but the result still counts
	// as a rule fire in the cycle summary log.
	c, _, _, _, _, _, _, _, _, not, _ := baseCycle()
	c.NotifyOnlyCRIT = true

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 0 {
		t.Errorf("WARN should be suppressed, notifier sent = %v", not.sent)
	}
}

func TestRun_NotifyOnlyCRIT_SendsCrit(t *testing.T) {
	c, _, _, _, _, _, eng, _, _, not, _ := baseCycle()
	c.NotifyOnlyCRIT = true
	eng.results = []types.RuleResult{
		{RuleID: "D-001", Entity: "Account", Level: "CRIT", Breached: true},
	}

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 {
		t.Errorf("CRIT should be notified, notifier sent = %v", not.sent)
	}
}

func TestRun_NotifyOnlyCRIT_MixedResults(t *testing.T) {
	c, _, _, _, _, _, eng, _, _, not, _ := baseCycle()
	c.NotifyOnlyCRIT = true
	eng.results = []types.RuleResult{
		{RuleID: "D-001", Entity: "Account", Level: "WARN", Breached: true},
		{RuleID: "D-002", Entity: "Contact", Level: "CRIT", Breached: true},
		{RuleID: "D-003", Entity: "Lead", Level: "WARN", Breached: true},
	}

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 {
		t.Errorf("exactly one CRIT should be notified, got %d (%v)", len(not.sent), not.sent)
	}
}

func TestRun_EnvLabel_PrefixesMessage(t *testing.T) {
	c, _, _, _, _, _, _, _, _, not, _ := baseCycle()
	c.EnvLabel = "prod"

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 {
		t.Fatalf("expected 1 notification, got %d", len(not.sent))
	}
	if !contains(not.sent[0], "[prod]") {
		t.Errorf("expected prefix '[prod]' in %q", not.sent[0])
	}
	if !contains(not.sent[0], "LLM-rendered") {
		t.Errorf("expected translated body preserved in %q", not.sent[0])
	}
}

func TestRun_NoEnvLabel_NoPrefix(t *testing.T) {
	c, _, _, _, _, _, _, _, _, not, _ := baseCycle()
	// EnvLabel defaults to "" — no prefix

	if err := c.Run(context.Background()); err != nil {
		t.Fatalf("Run returned error: %v", err)
	}
	if len(not.sent) != 1 || not.sent[0] != "LLM-rendered" {
		t.Errorf("expected bare 'LLM-rendered', got %v", not.sent)
	}
}

// contains is a tiny helper to keep the assertions readable.
func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}
