package service

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"gorm.io/gorm"

	coremodel "security-center-backend/internal/model"
	alertmodel "security-center-backend/internal/modules/alerts/model"
	alertrepo "security-center-backend/internal/modules/alerts/repository"
	vulnmodel "security-center-backend/internal/modules/vuln/model"
	"security-center-backend/internal/service/directory"
)

type fakeRuleRepo struct {
	rules []alertmodel.LarkAlertRule
	err   error
}

func (f *fakeRuleRepo) List(ctx context.Context) ([]alertmodel.LarkAlertRule, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.rules, nil
}
func (f *fakeRuleRepo) Get(ctx context.Context, id uint64) (*alertmodel.LarkAlertRule, error) {
	for i := range f.rules {
		if f.rules[i].ID == id {
			rule := f.rules[i]
			return &rule, nil
		}
	}
	return nil, errors.New("not found")
}
func (f *fakeRuleRepo) Create(ctx context.Context, rule *alertmodel.LarkAlertRule) error {
	if rule == nil {
		return nil
	}
	copy := *rule
	copy.ID = uint64(len(f.rules) + 1)
	f.rules = append(f.rules, copy)
	return nil
}
func (f *fakeRuleRepo) Update(ctx context.Context, rule *alertmodel.LarkAlertRule) error {
	if rule == nil {
		return nil
	}
	for i := range f.rules {
		if f.rules[i].ID == rule.ID {
			f.rules[i] = *rule
			return nil
		}
	}
	f.rules = append(f.rules, *rule)
	return nil
}
func (f *fakeRuleRepo) Delete(ctx context.Context, id uint64) error { return nil }

type fakeRunRepo struct {
	runs []alertmodel.LarkAlertRun
}

func (f *fakeRunRepo) Create(ctx context.Context, run *alertmodel.LarkAlertRun) error {
	run.ID = uint64(len(f.runs) + 1)
	f.runs = append(f.runs, *run)
	return nil
}
func (f *fakeRunRepo) Update(ctx context.Context, run *alertmodel.LarkAlertRun) error { return nil }
func (f *fakeRunRepo) ListRecent(ctx context.Context, limit int) ([]alertmodel.LarkAlertRun, error) {
	return f.runs, nil
}
func (f *fakeRunRepo) Get(ctx context.Context, id uint64) (*alertmodel.LarkAlertRun, error) {
	return nil, errors.New("not found")
}

type fakeDeliveryRepo struct {
	deliveries []alertmodel.LarkAlertDelivery
}

func (f *fakeDeliveryRepo) Create(ctx context.Context, delivery *alertmodel.LarkAlertDelivery) error {
	delivery.ID = uint64(len(f.deliveries) + 1)
	f.deliveries = append(f.deliveries, *delivery)
	return nil
}
func (f *fakeDeliveryRepo) ListByRun(ctx context.Context, runID uint64, limit int) ([]alertmodel.LarkAlertDelivery, error) {
	return f.deliveries, nil
}

type flakyDeliveryRepo struct {
	deliveries []alertmodel.LarkAlertDelivery
}

func (f *flakyDeliveryRepo) Create(ctx context.Context, delivery *alertmodel.LarkAlertDelivery) error {
	if delivery.CardPreview != nil {
		return errors.New("simulated card preview persistence failure")
	}
	delivery.ID = uint64(len(f.deliveries) + 1)
	f.deliveries = append(f.deliveries, *delivery)
	return nil
}

func (f *flakyDeliveryRepo) ListByRun(ctx context.Context, runID uint64, limit int) ([]alertmodel.LarkAlertDelivery, error) {
	return f.deliveries, nil
}

type fakeEntityStore struct {
	fields []FieldInfo
	matchs []map[string]interface{}
	entity map[string]interface{}
}

func (f *fakeEntityStore) GetEntityFields(ctx context.Context, entityType string) ([]FieldInfo, error) {
	return f.fields, nil
}
func (f *fakeEntityStore) FindMatches(ctx context.Context, entityType string, entityID *uint64, rawConditions interface{}, limit int) ([]map[string]interface{}, error) {
	return f.matchs, nil
}
func (f *fakeEntityStore) FetchEntity(ctx context.Context, entityType string, id uint64) (map[string]interface{}, error) {
	return f.entity, nil
}

type fakeEmployeeLookup struct {
	called bool
	emp    *directory.Employee
}

func (f *fakeEmployeeLookup) FindByID(ctx context.Context, id uint64) (*directory.Employee, error) {
	f.called = true
	if f.emp == nil {
		return nil, errors.New("missing employee")
	}
	return f.emp, nil
}
func (f *fakeEmployeeLookup) ListActive(ctx context.Context) ([]directory.Employee, error) {
	if f.emp == nil {
		return []directory.Employee{}, nil
	}
	return []directory.Employee{*f.emp}, nil
}
func (f *fakeEmployeeLookup) ListByDepartment(ctx context.Context, departmentID string) ([]directory.Employee, error) {
	if f.emp == nil {
		return []directory.Employee{}, nil
	}
	return []directory.Employee{*f.emp}, nil
}

type fakeEmployeeLookupByID struct {
	employees map[uint64]directory.Employee
}

func (f *fakeEmployeeLookupByID) FindByID(ctx context.Context, id uint64) (*directory.Employee, error) {
	emp, ok := f.employees[id]
	if !ok {
		return nil, errors.New("missing employee")
	}
	return &emp, nil
}
func (f *fakeEmployeeLookupByID) ListActive(ctx context.Context) ([]directory.Employee, error) {
	employees := make([]directory.Employee, 0, len(f.employees))
	for _, emp := range f.employees {
		employees = append(employees, emp)
	}
	return employees, nil
}
func (f *fakeEmployeeLookupByID) ListByDepartment(ctx context.Context, departmentID string) ([]directory.Employee, error) {
	employees := make([]directory.Employee, 0, len(f.employees))
	for _, emp := range f.employees {
		if emp.DepartmentID == departmentID {
			employees = append(employees, emp)
		}
	}
	return employees, nil
}

type fakeSubmitterLookup struct {
	employeeID uint64
	err        error
}

func (f *fakeSubmitterLookup) FindEmployeeIDByUserID(ctx context.Context, userID uint64) (uint64, error) {
	if f.err != nil {
		return 0, f.err
	}
	return f.employeeID, nil
}

type fakeEventFeed struct {
	creates []VulnerabilityCreateRef
	events  []VulnerabilityEventRef
	history []AssetHistoryRef
}

func (f *fakeEventFeed) ListVulnerabilityCreates(ctx context.Context, lastVulnerabilityID uint64, limit int) ([]VulnerabilityCreateRef, error) {
	out := make([]VulnerabilityCreateRef, 0, len(f.creates))
	for _, item := range f.creates {
		if item.ID > lastVulnerabilityID {
			out = append(out, item)
		}
	}
	return out, nil
}
func (f *fakeEventFeed) ListVulnerabilityEvents(ctx context.Context, lastEventID uint64, limit int) ([]VulnerabilityEventRef, error) {
	out := make([]VulnerabilityEventRef, 0, len(f.events))
	for _, item := range f.events {
		if item.ID > lastEventID {
			out = append(out, item)
		}
	}
	return out, nil
}
func (f *fakeEventFeed) ListAssetHistoryChanges(ctx context.Context, lastHistoryID uint64, limit int) ([]AssetHistoryRef, error) {
	out := make([]AssetHistoryRef, 0, len(f.history))
	for _, item := range f.history {
		if item.ID > lastHistoryID {
			out = append(out, item)
		}
	}
	return out, nil
}

type fakeRuntimeOffsetRepo struct {
	offsets map[string]uint64
}

func (f *fakeRuntimeOffsetRepo) Get(ctx context.Context, key string) (uint64, error) {
	if f.offsets == nil {
		return 0, nil
	}
	return f.offsets[key], nil
}

func (f *fakeRuntimeOffsetRepo) Advance(ctx context.Context, key string, lastProcessedID uint64) error {
	if f.offsets == nil {
		f.offsets = make(map[string]uint64)
	}
	if f.offsets[key] < lastProcessedID {
		f.offsets[key] = lastProcessedID
	}
	return nil
}

type fakeSettingsService struct {
	snapshot SettingsSnapshot
}

func (f *fakeSettingsService) Get(ctx context.Context) (SettingsSnapshot, error) {
	return f.snapshot, nil
}

type fakeSLAReminderRepo struct {
	dueSoon          []alertrepo.VulnerabilityReminderCandidate
	overdue          []alertrepo.VulnerabilityReminderCandidate
	deliveries       []alertmodel.VulnerabilitySLAReminderDelivery
	refreshFlagCalls int
}

func (f *fakeSLAReminderRepo) ListDueSoonCandidates(ctx context.Context, now time.Time, windowEnd time.Time, limit int) ([]alertrepo.VulnerabilityReminderCandidate, error) {
	return f.dueSoon, nil
}

func (f *fakeSLAReminderRepo) ListOverdueCandidates(ctx context.Context, now time.Time, limit int) ([]alertrepo.VulnerabilityReminderCandidate, error) {
	return f.overdue, nil
}

func (f *fakeSLAReminderRepo) RefreshOverdueFlags(ctx context.Context, now time.Time) error {
	f.refreshFlagCalls++
	return nil
}

func (f *fakeSLAReminderRepo) LastSuccessfulDelivery(ctx context.Context, vulnerabilityID uint64, kind alertmodel.SLAReminderKind, recipientEmployeeID uint64, slaDeadline time.Time) (*alertmodel.VulnerabilitySLAReminderDelivery, error) {
	var latest *alertmodel.VulnerabilitySLAReminderDelivery
	for i := range f.deliveries {
		d := &f.deliveries[i]
		if d.VulnerabilityID != vulnerabilityID ||
			d.ReminderKind != kind ||
			d.RecipientEmployeeID != recipientEmployeeID ||
			!d.SLADeadlineSnapshot.Equal(slaDeadline) ||
			d.Status != alertmodel.DeliveryStatusSuccess {
			continue
		}
		if latest == nil || d.SentAt.After(latest.SentAt) {
			latest = d
		}
	}
	if latest == nil {
		return nil, gorm.ErrRecordNotFound
	}
	return latest, nil
}

func (f *fakeSLAReminderRepo) Create(ctx context.Context, delivery *alertmodel.VulnerabilitySLAReminderDelivery) error {
	if delivery == nil {
		return nil
	}
	copy := *delivery
	copy.ID = uint64(len(f.deliveries) + 1)
	f.deliveries = append(f.deliveries, copy)
	return nil
}

func vulnerabilityReminderCandidate(id uint64, deadline time.Time, ownerEmployeeID *uint64, submitterUserID uint64) alertrepo.VulnerabilityReminderCandidate {
	return alertrepo.VulnerabilityReminderCandidate{Vulnerability: vulnmodel.Vulnerability{
		Base:            coremodel.Base{ID: id},
		Title:           "SQL injection",
		SeverityLevel:   vulnmodel.SeverityHigh,
		Status:          vulnmodel.StatusActive,
		Application:     "portal",
		OwnerEmployeeID: ownerEmployeeID,
		SubmitterUserID: submitterUserID,
		SLADeadline:     deadline,
	}}
}

func TestServiceUsesEntityStoreAndEmployeeLookup(t *testing.T) {
	ctx := context.Background()
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{{
		Base:                 coremodel.Base{ID: 1},
		Name:                 "alert",
		Enabled:              true,
		EntityType:           "asset",
		SendViaApp:           true,
		RecipientEmployeeIDs: alertmodel.UInt64Array{99},
		CardTemplate:         `{"title":"{{ .entity.name }}"}`,
		MaxMatchesPerRun:     1,
	}}}
	runs := &fakeRunRepo{}
	deliveries := &fakeDeliveryRepo{}
	store := &fakeEntityStore{
		fields: []FieldInfo{{Name: "name", Type: "string"}},
		matchs: []map[string]interface{}{{"name": "web-1"}},
		entity: map[string]interface{}{"name": "web-1"},
	}
	employees := &fakeEmployeeLookup{emp: &directory.Employee{ID: 99, Email: "dev@example.com", LarkUserID: "ou_xxx"}}
	submitters := &fakeSubmitterLookup{employeeID: 99}
	settingsSvc := &fakeSettingsService{snapshot: SettingsSnapshot{
		Notifications: NotificationSettings{
			LarkAlerts: true,
		},
	}}
	svc := NewWithPorts(store, employees, submitters, rules, runs, deliveries, settingsSvc)

	entityID := uint64(1)
	card, err := svc.ValidateAndRenderTemplate(ctx, "asset", &entityID, `{"title":"{{ .entity.name }}"}`)
	require.NoError(t, err)
	require.Equal(t, "web-1", card["title"])

	run, err := svc.runRule(ctx, &rules.rules[0], alertmodel.TriggerSourceManual, "", nil)
	require.NoError(t, err)
	require.True(t, employees.called)
	require.Equal(t, alertmodel.RunStatusError, run.Status)
	require.Equal(t, 1, run.DeliveriesFailed)
	require.Len(t, deliveries.deliveries, 1)
}

func TestRenderCardTemplateEnrichesVulnerabilityLabelsAndURL(t *testing.T) {
	t.Setenv("SECURITY_CENTER_PUBLIC_BASE_URL", "https://security.example.com/")
	svc := NewWithPorts(
		nil,
		&fakeEmployeeLookupByID{employees: map[uint64]directory.Employee{
			32: {ID: 32, Name: "Alice Owner", Email: "alice@example.com"},
			33: {ID: 33, Name: "Bob Fixer"},
			34: {ID: 34, Email: "carol@example.com"},
		}},
		&fakeSubmitterLookup{employeeID: 34},
		&fakeRuleRepo{},
		&fakeRunRepo{},
		&fakeDeliveryRepo{},
		&fakeSettingsService{},
	)

	card, err := svc.renderCardTemplate(context.Background(), "vulnerability", map[string]interface{}{
		"public_id":         "VULN-2026-0001",
		"title":             "SQL injection",
		"owner_employee_id": uint64(32),
		"fixer_employee_id": uint64(33),
		"submitter_user_id": uint64(7),
		"severity_level":    "high",
		"application":       "portal",
		"status":            "active",
	}, defaultVulnerabilitySubmissionCardTemplate)
	require.NoError(t, err)

	elements, ok := card["elements"].([]interface{})
	require.True(t, ok)
	require.NotEmpty(t, elements)
	firstElement, ok := elements[0].(map[string]interface{})
	require.True(t, ok)
	content, _ := firstElement["content"].(string)
	require.Contains(t, content, "Owner: Alice Owner (alice@example.com)")
	require.Contains(t, content, "Submitter: carol@example.com")
	require.NotContains(t, content, "Owner: 32")
	require.Contains(t, content, "[View vulnerability](https://security.example.com/dashboard/vulnerabilities/VULN-2026-0001)")
}

func TestVulnerabilitySLAReminderRuntimeSendsDueSoonOncePerDeadlineAndRecipient(t *testing.T) {
	now := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
	ownerID := uint64(42)
	repo := &fakeSLAReminderRepo{
		dueSoon: []alertrepo.VulnerabilityReminderCandidate{
			vulnerabilityReminderCandidate(1, now.Add(2*time.Hour), &ownerID, 7),
		},
	}
	settingsSvc := &fakeSettingsService{snapshot: SettingsSnapshot{Notifications: NotificationSettings{
		LarkAlerts:                         true,
		LarkAppID:                          "cli_test",
		LarkAppSecret:                      "secret",
		LarkReceiveIDType:                  "open_id",
		VulnerabilitySLARemindersEnabled:   true,
		VulnerabilitySLADueSoonBeforeHours: 24,
		VulnerabilitySLAOverdueRepeatHours: 24,
	}}}
	employees := &fakeEmployeeLookup{emp: &directory.Employee{ID: ownerID, LarkUserID: "ou_owner"}}
	submitters := &fakeSubmitterLookup{employeeID: ownerID}
	svc := NewWithPorts(nil, employees, submitters, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, settingsSvc)
	runtime := NewVulnerabilitySLAReminderRuntime(svc, repo, nil)
	sendCount := 0
	runtime.sendCard = func(ctx context.Context, appID string, appSecret string, receiveIDType string, receiveID string, card map[string]interface{}) error {
		sendCount++
		require.Equal(t, "open_id", receiveIDType)
		require.Equal(t, "ou_owner", receiveID)
		return nil
	}

	require.NoError(t, runtime.RunOnce(context.Background(), now))
	require.NoError(t, runtime.RunOnce(context.Background(), now.Add(10*time.Minute)))
	repo.dueSoon[0].SLADeadline = now.Add(3 * time.Hour)
	require.NoError(t, runtime.RunOnce(context.Background(), now.Add(20*time.Minute)))

	require.Equal(t, 2, sendCount)
	require.Equal(t, 3, repo.refreshFlagCalls)
	require.Len(t, repo.deliveries, 2)
	require.Equal(t, alertmodel.SLAReminderKindDueSoon, repo.deliveries[0].ReminderKind)
	require.Equal(t, alertmodel.DeliveryStatusSuccess, repo.deliveries[0].Status)
	require.Equal(t, "entity_owner,entity_submitter", repo.deliveries[0].RecipientLabel)
}

func TestVulnerabilitySLAReminderRuntimeOverdueCooldownUsesOnlySuccesses(t *testing.T) {
	now := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
	ownerID := uint64(42)
	repo := &fakeSLAReminderRepo{
		overdue: []alertrepo.VulnerabilityReminderCandidate{
			vulnerabilityReminderCandidate(2, now.Add(-time.Hour), &ownerID, 7),
		},
	}
	settingsSvc := &fakeSettingsService{snapshot: SettingsSnapshot{Notifications: NotificationSettings{
		LarkAlerts:                         true,
		LarkAppID:                          "cli_test",
		LarkAppSecret:                      "secret",
		LarkReceiveIDType:                  "open_id",
		VulnerabilitySLARemindersEnabled:   true,
		VulnerabilitySLADueSoonBeforeHours: 24,
		VulnerabilitySLAOverdueRepeatHours: 24,
	}}}
	employees := &fakeEmployeeLookup{emp: &directory.Employee{ID: ownerID, LarkUserID: "ou_owner"}}
	submitters := &fakeSubmitterLookup{employeeID: ownerID}
	svc := NewWithPorts(nil, employees, submitters, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, settingsSvc)
	runtime := NewVulnerabilitySLAReminderRuntime(svc, repo, nil)
	sendCount := 0
	runtime.sendCard = func(ctx context.Context, appID string, appSecret string, receiveIDType string, receiveID string, card map[string]interface{}) error {
		sendCount++
		return nil
	}

	require.NoError(t, runtime.RunOnce(context.Background(), now))
	require.NoError(t, runtime.RunOnce(context.Background(), now.Add(23*time.Hour)))
	require.NoError(t, runtime.RunOnce(context.Background(), now.Add(25*time.Hour)))

	require.Equal(t, 2, sendCount)
	successes := 0
	for i := range repo.deliveries {
		if repo.deliveries[i].Status == alertmodel.DeliveryStatusSuccess {
			successes++
		}
	}
	require.Equal(t, 2, successes)

	failingRepo := &fakeSLAReminderRepo{overdue: repo.overdue}
	failingRuntime := NewVulnerabilitySLAReminderRuntime(svc, failingRepo, nil)
	failCount := 0
	failingRuntime.sendCard = func(ctx context.Context, appID string, appSecret string, receiveIDType string, receiveID string, card map[string]interface{}) error {
		failCount++
		return errors.New("lark unavailable")
	}

	require.NoError(t, failingRuntime.RunOnce(context.Background(), now))
	require.NoError(t, failingRuntime.RunOnce(context.Background(), now.Add(time.Minute)))

	require.Equal(t, 2, failCount)
	require.Len(t, failingRepo.deliveries, 2)
	require.Equal(t, alertmodel.DeliveryStatusError, failingRepo.deliveries[0].Status)
	require.Equal(t, alertmodel.DeliveryStatusError, failingRepo.deliveries[1].Status)
}

func TestRuntimeControlUsesEventFeed(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{{
		Base:             coremodel.Base{ID: 1},
		Name:             "alert",
		Enabled:          true,
		EntityType:       "vulnerability",
		EventOnCreate:    true,
		CardTemplate:     `{"title":"x"}`,
		MaxMatchesPerRun: 1,
	}}}
	runs := &fakeRunRepo{}
	offsets := &fakeRuntimeOffsetRepo{offsets: map[string]uint64{
		alertOffsetVulnerabilityCreate: 7,
	}}
	svc := NewWithPorts(nil, nil, nil, rules, runs, &fakeDeliveryRepo{}, &fakeSettingsService{})
	rc := NewAlertRuntimeControlWithFeedAndOffsets(svc, rules, &fakeEventFeed{
		creates: []VulnerabilityCreateRef{{ID: 7}, {ID: 8}},
		events:  []VulnerabilityEventRef{{ID: 1, VulnerabilityID: 7, EventType: "status_changed"}},
		history: []AssetHistoryRef{{ID: 2, AssetID: 8, Action: "create"}},
	}, offsets, nil)

	status, err := rc.Start()
	require.NoError(t, err)
	require.True(t, status.SchedulerRunning)
	rc.tick()

	require.Len(t, runs.runs, 1)
	require.Equal(t, uint64(8), offsets.offsets[alertOffsetVulnerabilityCreate])
	require.Equal(t, uint64(1), offsets.offsets[alertOffsetVulnerabilityEvent])
	require.Equal(t, uint64(2), offsets.offsets[alertOffsetAssetHistory])
	rc.Stop()
}

func TestRuntimeControlRestoresOffsetsAcrossRestart(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{{
		Base:             coremodel.Base{ID: 1},
		Name:             "alert",
		Enabled:          true,
		EntityType:       "vulnerability",
		EventOnCreate:    true,
		CardTemplate:     `{"title":"x"}`,
		MaxMatchesPerRun: 1,
	}}}
	offsets := &fakeRuntimeOffsetRepo{}
	feed := &fakeEventFeed{creates: []VulnerabilityCreateRef{{ID: 10}}}

	firstRuns := &fakeRunRepo{}
	firstSvc := NewWithPorts(nil, nil, nil, rules, firstRuns, &fakeDeliveryRepo{}, &fakeSettingsService{})
	firstRuntime := NewAlertRuntimeControlWithFeedAndOffsets(firstSvc, rules, feed, offsets, nil)
	status, err := firstRuntime.Start()
	require.NoError(t, err)
	require.True(t, status.SchedulerRunning)
	firstRuntime.tick()
	firstRuntime.Stop()

	require.Len(t, firstRuns.runs, 1)
	require.Equal(t, uint64(10), offsets.offsets[alertOffsetVulnerabilityCreate])

	secondRuns := &fakeRunRepo{}
	secondSvc := NewWithPorts(nil, nil, nil, rules, secondRuns, &fakeDeliveryRepo{}, &fakeSettingsService{})
	secondRuntime := NewAlertRuntimeControlWithFeedAndOffsets(secondSvc, rules, feed, offsets, nil)
	status, err = secondRuntime.Start()
	require.NoError(t, err)
	require.True(t, status.SchedulerRunning)
	secondRuntime.tick()
	secondRuntime.Stop()

	require.Empty(t, secondRuns.runs)
	require.Equal(t, uint64(10), offsets.offsets[alertOffsetVulnerabilityCreate])
}

func TestRuntimeControlClearsDiagnosticAfterSuccessfulStart(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{}}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})
	rc := NewAlertRuntimeControlWithFeed(svc, rules, &fakeEventFeed{}, nil)

	diagnostic := rc.StopWithDiagnostic("lark alerts enabled but no Lark app credentials or webhook are configured")
	require.Equal(t, "lark alerts enabled but no Lark app credentials or webhook are configured", diagnostic.LastError)

	status, err := rc.Start()
	require.NoError(t, err)
	require.True(t, status.SchedulerRunning)
	require.Empty(t, status.LastError)
	require.Empty(t, status.LastErrorAt)
	rc.Stop()
}

func TestRecordDeliveryRetriesWithoutCardPreview(t *testing.T) {
	repo := &flakyDeliveryRepo{}
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, repo, &fakeSettingsService{})

	ruleID := uint64(2)
	run := &alertmodel.LarkAlertRun{Base: coremodel.Base{ID: 99}, RuleID: &ruleID}
	card := map[string]interface{}{
		"header": map[string]interface{}{
			"title": "Phase 5",
		},
	}

	svc.recordDelivery(
		context.Background(),
		run,
		&alertmodel.LarkAlertRule{Base: coremodel.Base{ID: ruleID}},
		alertmodel.RecipientTypeEmployee,
		"entity_owner",
		func() *uint64 { id := uint64(2); return &id }(),
		"",
		alertmodel.DeliveryStatusError,
		nil,
		"simulated downstream failure",
		"",
		card,
	)

	require.Len(t, repo.deliveries, 1)
	require.Nil(t, repo.deliveries[0].CardPreview)
	require.Equal(t, "entity_owner", repo.deliveries[0].RecipientLabel)
}

func TestEntityEmployeeIDDereferencesPointers(t *testing.T) {
	ownerID := uint64(2)
	fixerID := uint64(4)

	require.Equal(t, uint64(2), entityEmployeeID(map[string]interface{}{
		"owner_employee_id": &ownerID,
	}, "owner_employee_id"))
	require.Equal(t, uint64(4), entityEmployeeID(map[string]interface{}{
		"fixer_employee_id": &fixerID,
	}, "fixer_employee_id"))
}

func TestEntityDepartmentIDDereferencesStringPointers(t *testing.T) {
	departmentID := "dep-secops"

	require.Equal(t, "dep-secops", entityDepartmentID(map[string]interface{}{
		"department_id": &departmentID,
	}))
}

func TestResolveRecipientsForSubmitterUsesSubmitterLookup(t *testing.T) {
	svc := NewWithPorts(nil, nil, &fakeSubmitterLookup{employeeID: 42}, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	recipients, err := svc.resolveRecipientsForTarget(context.Background(), string(alertmodel.RecipientTargetEntitySubmitter), map[string]interface{}{
		"submitter_user_id": uint64(7),
	})
	require.NoError(t, err)
	require.Len(t, recipients, 1)
	require.Equal(t, uint64(42), recipients[0].EmployeeID)
	require.Equal(t, string(alertmodel.RecipientTargetEntitySubmitter), recipients[0].Label)
}

func TestResolveEmployeeRecipientsSkipsMissingFixerWhenSubmitterResolves(t *testing.T) {
	svc := NewWithPorts(nil, &fakeEmployeeLookup{}, &fakeSubmitterLookup{employeeID: 42}, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	recipients, blocked, err := svc.resolveEmployeeRecipients(context.Background(), &alertmodel.LarkAlertRule{
		RecipientTargetKeys: alertmodel.StringArray{
			string(alertmodel.RecipientTargetEntityFixer),
			string(alertmodel.RecipientTargetEntitySubmitter),
		},
	}, map[string]interface{}{
		"submitter_user_id": uint64(7),
	})

	require.NoError(t, err)
	require.Empty(t, blocked)
	require.Len(t, recipients, 1)
	require.Equal(t, uint64(42), recipients[0].EmployeeID)
	require.Equal(t, string(alertmodel.RecipientTargetEntitySubmitter), recipients[0].Label)
}

func TestEnsureDefaultVulnerabilitySubmissionRuleCreatesRuleWhenMissing(t *testing.T) {
	rules := &fakeRuleRepo{}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{
			LarkAlerts:    true,
			LarkAppID:     "cli_test",
			LarkAppSecret: "secret",
		}},
	})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Len(t, rules.rules, 7)

	names := make([]string, 0, len(rules.rules))
	for i := range rules.rules {
		names = append(names, rules.rules[i].Name)
		require.True(t, rules.rules[i].Enabled)
		require.Equal(t, "vulnerability", rules.rules[i].EntityType)
		require.True(t, rules.rules[i].SendViaApp)
		require.Equal(t, 1, rules.rules[i].MaxMatchesPerRun)
		require.NotEmpty(t, rules.rules[i].CardTemplate)
	}
	require.ElementsMatch(t, []string{
		defaultVulnerabilitySubmissionRuleName,
		defaultVulnerabilityHighSeverityRuleName,
		defaultVulnerabilityOwnerChangedRuleName,
		defaultVulnerabilityFixedRuleName,
		defaultVulnerabilityRetestingRuleName,
		defaultVulnerabilityClosedRuleName,
		defaultVulnerabilityReopenedRuleName,
	}, names)

	var submissionRule, highSeverityRule, ownerChangedRule, fixedRule, retestingRule, closedRule, reopenedRule *alertmodel.LarkAlertRule
	for i := range rules.rules {
		switch rules.rules[i].Name {
		case defaultVulnerabilitySubmissionRuleName:
			submissionRule = &rules.rules[i]
		case defaultVulnerabilityHighSeverityRuleName:
			highSeverityRule = &rules.rules[i]
		case defaultVulnerabilityOwnerChangedRuleName:
			ownerChangedRule = &rules.rules[i]
		case defaultVulnerabilityFixedRuleName:
			fixedRule = &rules.rules[i]
		case defaultVulnerabilityRetestingRuleName:
			retestingRule = &rules.rules[i]
		case defaultVulnerabilityClosedRuleName:
			closedRule = &rules.rules[i]
		case defaultVulnerabilityReopenedRuleName:
			reopenedRule = &rules.rules[i]
		}
	}
	require.NotNil(t, submissionRule)
	require.NotNil(t, highSeverityRule)
	require.NotNil(t, ownerChangedRule)
	require.NotNil(t, fixedRule)
	require.NotNil(t, retestingRule)
	require.NotNil(t, closedRule)
	require.NotNil(t, reopenedRule)
	require.True(t, submissionRule.EventOnCreate)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetEntityOwner), string(alertmodel.RecipientTargetEntitySubmitter)}, []string(submissionRule.RecipientTargetKeys))
	require.True(t, highSeverityRule.EventOnCreate)
	require.Equal(t, coremodel.JSONB{"conditions": []interface{}{map[string]interface{}{"field": "severity_level", "op": "in", "value": []interface{}{"critical", "high"}}}}, highSeverityRule.Conditions)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetSecurityTeam)}, []string(highSeverityRule.RecipientTargetKeys))
	require.True(t, ownerChangedRule.EventOnUpdate)
	require.Equal(t, coremodel.JSONB{"conditions": []interface{}{map[string]interface{}{"field": "owner_employee_id", "op": "not_null", "value": nil}}}, ownerChangedRule.Conditions)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetEntityOwner)}, []string(ownerChangedRule.RecipientTargetKeys))
	require.True(t, fixedRule.EventOnStatusChange)
	require.Equal(t, coremodel.JSONB{"conditions": []interface{}{map[string]interface{}{"field": "status", "op": "eq", "value": "fixed"}}}, fixedRule.Conditions)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetSecurityTeam)}, []string(fixedRule.RecipientTargetKeys))
	require.True(t, retestingRule.EventOnStatusChange)
	require.Equal(t, coremodel.JSONB{"conditions": []interface{}{map[string]interface{}{"field": "status", "op": "eq", "value": "retesting"}}}, retestingRule.Conditions)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetEntityOwner), string(alertmodel.RecipientTargetEntityFixer), string(alertmodel.RecipientTargetSecurityTeam)}, []string(retestingRule.RecipientTargetKeys))
	require.True(t, closedRule.EventOnStatusChange)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetSecurityTeam)}, []string(closedRule.RecipientTargetKeys))
	require.True(t, reopenedRule.EventOnStatusChange)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetEntityFixer), string(alertmodel.RecipientTargetSecurityTeam)}, []string(reopenedRule.RecipientTargetKeys))
}

func TestEnsureDefaultVulnerabilitySubmissionRuleSkipsWhenEquivalentRuleExists(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{
		{
			Base:          coremodel.Base{ID: 7},
			Name:          "existing submission",
			Enabled:       true,
			EntityType:    "vulnerability",
			EventOnCreate: true,
			SendViaApp:    true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetEntitySubmitter),
				string(alertmodel.RecipientTargetEntityOwner),
			},
			CardTemplate:     `{"title":"existing"}`,
			MaxMatchesPerRun: 1,
		},
		{
			Base:          coremodel.Base{ID: 11},
			Name:          "existing high severity",
			Enabled:       true,
			EntityType:    "vulnerability",
			EventOnCreate: true,
			SendViaApp:    true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetSecurityTeam),
			},
			Conditions:       newConditionsJSONB(Condition{Field: "severity_level", Operator: "in", Value: []interface{}{"critical", "high"}}),
			CardTemplate:     `{"title":"existing"}`,
			MaxMatchesPerRun: 1,
		},
		{
			Base:          coremodel.Base{ID: 12},
			Name:          "existing owner change",
			Enabled:       true,
			EntityType:    "vulnerability",
			EventOnUpdate: true,
			SendViaApp:    true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetEntityOwner),
			},
			Conditions:       newConditionsJSONB(Condition{Field: "owner_employee_id", Operator: "not_null"}),
			CardTemplate:     `{"title":"existing"}`,
			MaxMatchesPerRun: 1,
		},
		{
			Base:                coremodel.Base{ID: 8},
			Name:                "existing fixed",
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{string(alertmodel.RecipientTargetSecurityTeam)},
			Conditions:          newConditionsJSONB(Condition{Field: "status", Operator: "eq", Value: "fixed"}),
			CardTemplate:        `{"title":"existing"}`,
			MaxMatchesPerRun:    1,
		},
		{
			Base:                coremodel.Base{ID: 9},
			Name:                "existing close",
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{string(alertmodel.RecipientTargetSecurityTeam)},
			Conditions:          newConditionsJSONB(Condition{Field: "status", Operator: "in", Value: []interface{}{"false_positive", "risk_accepted"}}),
			CardTemplate:        `{"title":"existing"}`,
			MaxMatchesPerRun:    1,
		},
		{
			Base:                coremodel.Base{ID: 13},
			Name:                "existing retesting",
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetSecurityTeam),
				string(alertmodel.RecipientTargetEntityFixer),
				string(alertmodel.RecipientTargetEntityOwner),
			},
			Conditions:       newConditionsJSONB(Condition{Field: "status", Operator: "eq", Value: "retesting"}),
			CardTemplate:     `{"title":"existing"}`,
			MaxMatchesPerRun: 1,
		},
		{
			Base:                coremodel.Base{ID: 10},
			Name:                "existing reopen",
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetSecurityTeam),
				string(alertmodel.RecipientTargetEntityFixer),
			},
			Conditions:       newConditionsJSONB(Condition{Field: "status", Operator: "eq", Value: "active"}),
			CardTemplate:     `{"title":"existing"}`,
			MaxMatchesPerRun: 1,
		},
	}}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Len(t, rules.rules, 7)
	require.Equal(t, uint64(7), rules.rules[0].ID)
	require.Equal(t, uint64(11), rules.rules[1].ID)
	require.Equal(t, uint64(12), rules.rules[2].ID)
	require.Equal(t, uint64(8), rules.rules[3].ID)
	require.Equal(t, uint64(9), rules.rules[4].ID)
	require.Equal(t, uint64(13), rules.rules[5].ID)
	require.Equal(t, uint64(10), rules.rules[6].ID)
}

func TestEnsureDefaultVulnerabilitySubmissionRuleCreatesOnlyMissingDefaults(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{{
		Base:          coremodel.Base{ID: 7},
		Name:          "existing custom submission",
		Enabled:       true,
		EntityType:    "vulnerability",
		EventOnCreate: true,
		SendViaApp:    true,
		RecipientTargetKeys: alertmodel.StringArray{
			string(alertmodel.RecipientTargetEntitySubmitter),
			string(alertmodel.RecipientTargetEntityFixer),
		},
		CardTemplate:     `{"title":"existing"}`,
		MaxMatchesPerRun: 1,
	}}}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{
			LarkAlerts:    true,
			LarkAppID:     "cli_test",
			LarkAppSecret: "secret",
		}},
	})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Len(t, rules.rules, 8)

	names := make([]string, 0, len(rules.rules))
	for i := range rules.rules {
		names = append(names, rules.rules[i].Name)
	}
	require.Contains(t, names, "existing custom submission")
	require.Contains(t, names, defaultVulnerabilitySubmissionRuleName)
	require.Contains(t, names, defaultVulnerabilityHighSeverityRuleName)
	require.Contains(t, names, defaultVulnerabilityOwnerChangedRuleName)
	require.Contains(t, names, defaultVulnerabilityFixedRuleName)
	require.Contains(t, names, defaultVulnerabilityRetestingRuleName)
	require.Contains(t, names, defaultVulnerabilityClosedRuleName)
	require.Contains(t, names, defaultVulnerabilityReopenedRuleName)
}

func TestEnsureDefaultVulnerabilitySubmissionRuleRepairsLegacyDefaultSubmissionRule(t *testing.T) {
	rules := &fakeRuleRepo{rules: []alertmodel.LarkAlertRule{{
		Base:          coremodel.Base{ID: 7},
		Name:          defaultVulnerabilitySubmissionRuleName,
		Enabled:       true,
		EntityType:    "vulnerability",
		EventOnCreate: true,
		SendViaApp:    true,
		RecipientTargetKeys: alertmodel.StringArray{
			string(alertmodel.RecipientTargetEntitySubmitter),
			string(alertmodel.RecipientTargetEntityFixer),
		},
		CardTemplate:     `{"title":"existing"}`,
		MaxMatchesPerRun: 1,
	}}}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{
			LarkAlerts:    true,
			LarkAppID:     "cli_test",
			LarkAppSecret: "secret",
		}},
	})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Len(t, rules.rules, 7)
	require.Equal(t, uint64(7), rules.rules[0].ID)
	require.ElementsMatch(t, []string{string(alertmodel.RecipientTargetEntityOwner), string(alertmodel.RecipientTargetEntitySubmitter)}, []string(rules.rules[0].RecipientTargetKeys))
}

func TestEnsureDefaultVulnerabilitySubmissionRuleUsesWebhookWhenAppCredentialsMissing(t *testing.T) {
	rules := &fakeRuleRepo{}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{
			LarkAlerts:  true,
			LarkWebhook: "https://example.test/webhook",
		}},
	})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Len(t, rules.rules, 7)
	for i := range rules.rules {
		require.False(t, rules.rules[i].SendViaApp)
		require.True(t, rules.rules[i].SendViaWebhook)
	}
}

func TestEnsureDefaultVulnerabilitySubmissionRuleSkipsWhenNoDeliveryConfigured(t *testing.T) {
	rules := &fakeRuleRepo{}
	svc := NewWithPorts(nil, nil, nil, rules, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{LarkAlerts: true}},
	})

	err := svc.EnsureDefaultVulnerabilitySubmissionRule(context.Background())
	require.NoError(t, err)
	require.Empty(t, rules.rules)
}

func TestCreateRuleRejectsRuleWithoutDeliveryPath(t *testing.T) {
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	err := svc.CreateRule(context.Background(), &alertmodel.LarkAlertRule{
		Name:       "invalid",
		Enabled:    true,
		EntityType: "vulnerability",
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "at least one delivery path")
}

func TestCreateRuleRejectsAppRuleWithoutRecipients(t *testing.T) {
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	err := svc.CreateRule(context.Background(), &alertmodel.LarkAlertRule{
		Name:       "invalid-app",
		Enabled:    true,
		EntityType: "vulnerability",
		SendViaApp: true,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "send via app requires")
}

func TestTestSendRejectsWhenNoDeliveryTargetSelected(t *testing.T) {
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{})

	run, err := svc.TestSend(context.Background(), nil, false, `{"title":"x"}`)

	require.Error(t, err)
	require.Nil(t, run)
	require.Contains(t, err.Error(), "at least one delivery path")
}

func TestTestSendDeliversWebhookWithoutEntityMatches(t *testing.T) {
	var received int
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		received++
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()

	runs := &fakeRunRepo{}
	deliveries := &fakeDeliveryRepo{}
	svc := NewWithPorts(&fakeEntityStore{}, nil, nil, &fakeRuleRepo{}, runs, deliveries, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{
			LarkAlerts:  true,
			LarkWebhook: server.URL,
		}},
	})
	svc.httpClient = server.Client()

	run, err := svc.TestSend(context.Background(), nil, true, `{"title":"test"}`)

	require.NoError(t, err)
	require.NotNil(t, run)
	require.Equal(t, alertmodel.RunStatusSuccess, run.Status)
	require.Equal(t, 1, run.MatchedCount)
	require.Equal(t, 1, run.DeliveriesTotal)
	require.Equal(t, 1, run.DeliveriesSuccess)
	require.Equal(t, 0, run.DeliveriesFailed)
	require.Equal(t, 1, received)
	require.Len(t, deliveries.deliveries, 1)
	require.Equal(t, alertmodel.DeliveryStatusSuccess, deliveries.deliveries[0].Status)
}

func TestExplainLarkEmployeeDeliveryErrorAddsOpenIDCrossAppGuidance(t *testing.T) {
	err := explainLarkEmployeeDeliveryError("open_id", errors.New("lark send message failed: status=400 msg=open_id cross app"))

	require.Error(t, err)
	require.Contains(t, err.Error(), "different app")
	require.Contains(t, err.Error(), "same Feishu app")
	require.Contains(t, err.Error(), "switch lark_receive_id_type to email")
}

func TestCreateRuleRejectsWebhookRuleWithoutConfiguredWebhook(t *testing.T) {
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{LarkAlerts: true}},
	})

	err := svc.CreateRule(context.Background(), &alertmodel.LarkAlertRule{
		Name:             "webhook-only",
		Enabled:          true,
		EntityType:       "vulnerability",
		SendViaWebhook:   true,
		CardTemplate:     `{"title":"x"}`,
		MaxMatchesPerRun: 1,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "configured lark webhook")
}

func TestCreateRuleRejectsAppRuleWithoutConfiguredAppCredentials(t *testing.T) {
	svc := NewWithPorts(nil, nil, nil, &fakeRuleRepo{}, &fakeRunRepo{}, &fakeDeliveryRepo{}, &fakeSettingsService{
		snapshot: SettingsSnapshot{Notifications: NotificationSettings{LarkAlerts: true}},
	})

	err := svc.CreateRule(context.Background(), &alertmodel.LarkAlertRule{
		Name:                 "app-only",
		Enabled:              true,
		EntityType:           "vulnerability",
		SendViaApp:           true,
		RecipientEmployeeIDs: alertmodel.UInt64Array{7},
		CardTemplate:         `{"title":"x"}`,
		MaxMatchesPerRun:     1,
	})

	require.Error(t, err)
	require.Contains(t, err.Error(), "configured lark app credentials")
}
