package service

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"reflect"
	"slices"
	"strings"
	"text/template"
	"time"

	"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"
	deptservice "security-center-backend/internal/modules/department/service"
	"security-center-backend/internal/pkg/lark"
	"security-center-backend/internal/pkg/logger"
	"security-center-backend/internal/service/directory"
)

type FieldInfo struct {
	Name string `json:"name"`
	Type string `json:"type"` // string|number|boolean|datetime|json|unknown
}

type RuntimeStatus struct {
	SchedulerRunning bool   `json:"scheduler_running"`
	ScheduledRules   int    `json:"scheduled_rules"`
	LastTickAt       string `json:"last_tick_at,omitempty"`
	LastError        string `json:"last_error,omitempty"`
	LastErrorAt      string `json:"last_error_at,omitempty"`
}

const (
	defaultVulnerabilitySubmissionRuleName       = "Default Vulnerability Submission Alert"
	defaultVulnerabilityFixedRuleName            = "Default Vulnerability Fixed Alert"
	defaultVulnerabilityClosedRuleName           = "Default Vulnerability Closed Alert"
	defaultVulnerabilityReopenedRuleName         = "Default Vulnerability Reopened Alert"
	defaultVulnerabilityHighSeverityRuleName     = "Default High Severity Vulnerability Alert"
	defaultVulnerabilityOwnerChangedRuleName     = "Default Vulnerability Owner Changed Alert"
	defaultVulnerabilityRetestingRuleName        = "Default Vulnerability Retesting Alert"
	defaultVulnerabilitySubmissionCardTemplate   = `{"config":{"wide_screen_mode":true},"header":{"template":"orange","title":{"tag":"plain_text","content":"New vulnerability submitted"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nStatus: {{ .entity.status }}\nOwner: {{ .entity.owner_label }}\nSubmitter: {{ .entity.submitter_label }}\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityFixedCardTemplate        = `{"config":{"wide_screen_mode":true},"header":{"template":"green","title":{"tag":"plain_text","content":"Vulnerability marked fixed"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nOwner: {{ .entity.owner_label }}\nFixer: {{ .entity.fixer_label }}\nAction: security review required\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityClosedCardTemplate       = `{"config":{"wide_screen_mode":true},"header":{"template":"grey","title":{"tag":"plain_text","content":"Vulnerability closed without fix"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nStatus: {{ .entity.status }}\nOwner: {{ .entity.owner_label }}\nAction: security review required\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityReopenedCardTemplate     = `{"config":{"wide_screen_mode":true},"header":{"template":"red","title":{"tag":"plain_text","content":"Vulnerability reopened"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nOwner: {{ .entity.owner_label }}\nFixer: {{ .entity.fixer_label }}\nAction: remediation needs attention\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityHighSeverityCardTemplate = `{"config":{"wide_screen_mode":true},"header":{"template":"red","title":{"tag":"plain_text","content":"High severity vulnerability submitted"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nOwner: {{ .entity.owner_label }}\nSubmitter: {{ .entity.submitter_label }}\nAction: security triage required\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityOwnerChangedCardTemplate = `{"config":{"wide_screen_mode":true},"header":{"template":"blue","title":{"tag":"plain_text","content":"Vulnerability owner updated"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nOwner: {{ .entity.owner_label }}\nStatus: {{ .entity.status }}\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
	defaultVulnerabilityRetestingCardTemplate    = `{"config":{"wide_screen_mode":true},"header":{"template":"orange","title":{"tag":"plain_text","content":"Vulnerability ready for retesting"}},"elements":[{"tag":"markdown","content":"**[{{ .entity.public_id }}] {{ .entity.title }}**\nSeverity: {{ .entity.severity_level }}\nApplication: {{ .entity.application }}\nOwner: {{ .entity.owner_label }}\nFixer: {{ .entity.fixer_label }}\nAction: verify remediation\n[View vulnerability]({{ .entity.vulnerability_url }})"}]}`
)

type Service struct {
	rules           alertrepo.RuleRepository
	runs            alertrepo.RunRepository
	deliveries      alertrepo.DeliveryRepository
	settingsSvc     any
	httpClient      *http.Client
	entityStore     EntityStore
	employeeLookup  EmployeeLookup
	submitterLookup SubmitterLookup
	logger          *logger.Logger
	orgSyncStatus   func() deptservice.RuntimeStatus
}

func New(db interface{}, rules alertrepo.RuleRepository, runs alertrepo.RunRepository, deliveries alertrepo.DeliveryRepository, settingsSvc any) *Service {
	return NewWithPorts(newGormEntityStore(asDB(db)), newGormEmployeeDirectory(asDB(db)), newGormSubmitterLookup(asDB(db)), rules, runs, deliveries, settingsSvc)
}

func NewWithPorts(entityStore EntityStore, employeeLookup EmployeeLookup, submitterLookup SubmitterLookup, rules alertrepo.RuleRepository, runs alertrepo.RunRepository, deliveries alertrepo.DeliveryRepository, settingsSvc any) *Service {
	return &Service{
		rules:           rules,
		runs:            runs,
		deliveries:      deliveries,
		settingsSvc:     settingsSvc,
		httpClient:      &http.Client{Timeout: 10 * time.Second},
		entityStore:     entityStore,
		employeeLookup:  employeeLookup,
		submitterLookup: submitterLookup,
	}
}

func (s *Service) SetLogger(log *logger.Logger) {
	s.logger = log
}

func (s *Service) SetOrgSyncStatusProvider(provider func() deptservice.RuntimeStatus) {
	s.orgSyncStatus = provider
}

func (s *Service) EnsureDefaultVulnerabilitySubmissionRule(ctx context.Context) error {
	if s == nil || s.rules == nil {
		return nil
	}
	delivery, ok, err := s.defaultVulnerabilityRuleDelivery(ctx)
	if err != nil {
		return err
	}
	if !ok {
		if s.logger != nil {
			s.logger.Warn("skipping default vulnerability alert rules because no Lark delivery path is configured")
		}
		return nil
	}
	rules, err := s.rules.List(ctx)
	if err != nil {
		return err
	}
	if err := s.repairLegacyDefaultVulnerabilitySubmissionRule(ctx, rules); err != nil {
		return err
	}
	rules, err = s.rules.List(ctx)
	if err != nil {
		return err
	}
	for _, rule := range defaultVulnerabilityLifecycleRules() {
		applyDefaultRuleDelivery(&rule, delivery)
		exists, existsErr := hasEquivalentDefaultVulnerabilityRule(rules, rule)
		if existsErr != nil {
			return existsErr
		}
		if exists {
			continue
		}
		ruleCopy := rule
		if err := s.CreateRule(ctx, &ruleCopy); err != nil {
			return err
		}
		rules = append(rules, ruleCopy)
	}
	return nil
}

type defaultRuleDelivery struct {
	SendViaApp     bool
	SendViaWebhook bool
}

func (s *Service) defaultVulnerabilityRuleDelivery(ctx context.Context) (defaultRuleDelivery, bool, error) {
	settings, err := s.getRawSettings(ctx)
	if err != nil {
		return defaultRuleDelivery{}, false, err
	}
	if !settings.Notifications.LarkAlerts {
		return defaultRuleDelivery{}, false, nil
	}
	hasAppCredentials := strings.TrimSpace(settings.Notifications.LarkAppID) != "" &&
		strings.TrimSpace(settings.Notifications.LarkAppSecret) != ""
	if hasAppCredentials {
		return defaultRuleDelivery{SendViaApp: true}, true, nil
	}
	if strings.TrimSpace(settings.Notifications.LarkWebhook) != "" {
		return defaultRuleDelivery{SendViaWebhook: true}, true, nil
	}
	return defaultRuleDelivery{}, false, nil
}

func applyDefaultRuleDelivery(rule *alertmodel.LarkAlertRule, delivery defaultRuleDelivery) {
	if rule == nil {
		return
	}
	rule.SendViaApp = delivery.SendViaApp
	rule.SendViaWebhook = delivery.SendViaWebhook
}

func defaultVulnerabilityLifecycleRules() []alertmodel.LarkAlertRule {
	return []alertmodel.LarkAlertRule{
		{
			Name:          defaultVulnerabilitySubmissionRuleName,
			Enabled:       true,
			EntityType:    "vulnerability",
			EventOnCreate: true,
			SendViaApp:    true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetEntityOwner),
				string(alertmodel.RecipientTargetEntitySubmitter),
			},
			CardTemplate:     defaultVulnerabilitySubmissionCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:          defaultVulnerabilityHighSeverityRuleName,
			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:     defaultVulnerabilityHighSeverityCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:          defaultVulnerabilityOwnerChangedRuleName,
			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:     defaultVulnerabilityOwnerChangedCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:                defaultVulnerabilityFixedRuleName,
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{string(alertmodel.RecipientTargetSecurityTeam)},
			Conditions: newConditionsJSONB(
				Condition{Field: "status", Operator: "eq", Value: "fixed"},
			),
			CardTemplate:     defaultVulnerabilityFixedCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:                defaultVulnerabilityRetestingRuleName,
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetEntityOwner),
				string(alertmodel.RecipientTargetEntityFixer),
				string(alertmodel.RecipientTargetSecurityTeam),
			},
			Conditions: newConditionsJSONB(
				Condition{Field: "status", Operator: "eq", Value: "retesting"},
			),
			CardTemplate:     defaultVulnerabilityRetestingCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:                defaultVulnerabilityClosedRuleName,
			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:     defaultVulnerabilityClosedCardTemplate,
			MaxMatchesPerRun: 1,
		},
		{
			Name:                defaultVulnerabilityReopenedRuleName,
			Enabled:             true,
			EntityType:          "vulnerability",
			EventOnStatusChange: true,
			SendViaApp:          true,
			RecipientTargetKeys: alertmodel.StringArray{
				string(alertmodel.RecipientTargetEntityFixer),
				string(alertmodel.RecipientTargetSecurityTeam),
			},
			Conditions: newConditionsJSONB(
				Condition{Field: "status", Operator: "eq", Value: "active"},
			),
			CardTemplate:     defaultVulnerabilityReopenedCardTemplate,
			MaxMatchesPerRun: 1,
		},
	}
}

func (s *Service) repairLegacyDefaultVulnerabilitySubmissionRule(ctx context.Context, rules []alertmodel.LarkAlertRule) error {
	for i := range rules {
		rule := rules[i]
		if !isLegacyDefaultVulnerabilitySubmissionRule(rule) {
			continue
		}
		rule.RecipientTargetKeys = alertmodel.StringArray{
			string(alertmodel.RecipientTargetEntityOwner),
			string(alertmodel.RecipientTargetEntitySubmitter),
		}
		if err := s.rules.Update(ctx, &rule); err != nil {
			return err
		}
	}
	return nil
}

func isLegacyDefaultVulnerabilitySubmissionRule(rule alertmodel.LarkAlertRule) bool {
	if strings.TrimSpace(rule.Name) != defaultVulnerabilitySubmissionRuleName {
		return false
	}
	if !rule.Enabled || !strings.EqualFold(strings.TrimSpace(rule.EntityType), "vulnerability") {
		return false
	}
	if !rule.EventOnCreate || rule.EventOnUpdate || rule.EventOnStatusChange || rule.EventOnSeverityChange {
		return false
	}
	conditions, err := normalizeRuleConditions(rule.Conditions)
	if err != nil || len(conditions.Conditions) != 0 {
		return false
	}
	targets, err := normalizeRecipientTargetKeys(rule.RecipientTargetKeys)
	if err != nil {
		return false
	}
	return reflect.DeepEqual([]string(targets), []string{
		string(alertmodel.RecipientTargetEntityFixer),
		string(alertmodel.RecipientTargetEntitySubmitter),
	})
}

func newConditionsJSONB(conditions ...Condition) coremodel.JSONB {
	if len(conditions) == 0 {
		return coremodel.JSONB{}
	}
	items := make([]interface{}, 0, len(conditions))
	for _, condition := range conditions {
		items = append(items, map[string]interface{}{
			"field": condition.Field,
			"op":    condition.Operator,
			"value": condition.Value,
		})
	}
	return coremodel.JSONB{
		"conditions": items,
	}
}

func hasEquivalentDefaultVulnerabilityRule(existing []alertmodel.LarkAlertRule, spec alertmodel.LarkAlertRule) (bool, error) {
	specTargets, err := normalizeRecipientTargetKeys(spec.RecipientTargetKeys)
	if err != nil {
		return false, err
	}
	specConditions, err := normalizeRuleConditions(spec.Conditions)
	if err != nil {
		return false, err
	}
	for i := range existing {
		rule := existing[i]
		if !rule.Enabled || !strings.EqualFold(strings.TrimSpace(rule.EntityType), spec.EntityType) {
			continue
		}
		ruleTargets, ruleTargetsErr := normalizeRecipientTargetKeys(rule.RecipientTargetKeys)
		if ruleTargetsErr != nil {
			continue
		}
		ruleConditions, ruleConditionsErr := normalizeRuleConditions(rule.Conditions)
		if ruleConditionsErr != nil {
			continue
		}
		if rule.EventOnCreate == spec.EventOnCreate &&
			rule.EventOnUpdate == spec.EventOnUpdate &&
			rule.EventOnStatusChange == spec.EventOnStatusChange &&
			rule.EventOnSeverityChange == spec.EventOnSeverityChange &&
			rule.SendViaApp == spec.SendViaApp &&
			rule.SendViaWebhook == spec.SendViaWebhook &&
			reflect.DeepEqual([]string(ruleTargets), []string(specTargets)) &&
			reflect.DeepEqual(ruleConditions, specConditions) {
			return true, nil
		}
	}
	return false, nil
}

func normalizeRuleConditions(raw interface{}) (RuleConditions, error) {
	if raw == nil {
		return RuleConditions{}, nil
	}
	var cond RuleConditions
	switch value := raw.(type) {
	case coremodel.JSONB:
		data, err := json.Marshal(value)
		if err != nil {
			return RuleConditions{}, err
		}
		if err := json.Unmarshal(data, &cond); err != nil {
			return RuleConditions{}, err
		}
	case map[string]interface{}:
		data, err := json.Marshal(value)
		if err != nil {
			return RuleConditions{}, err
		}
		if err := json.Unmarshal(data, &cond); err != nil {
			return RuleConditions{}, err
		}
	default:
		data, err := json.Marshal(value)
		if err != nil {
			return RuleConditions{}, err
		}
		if err := json.Unmarshal(data, &cond); err != nil {
			var legacy []Condition
			if legacyErr := json.Unmarshal(data, &legacy); legacyErr != nil {
				return RuleConditions{}, err
			}
			cond.Conditions = legacy
		}
	}
	if cond.Combinator == "" {
		cond.Combinator = "and"
	}
	for i := range cond.Conditions {
		if cond.Conditions[i].Operator == "" {
			cond.Conditions[i].Operator = cond.Conditions[i].effectiveOperator()
		}
		cond.Conditions[i].LegacyOperator = ""
	}
	return cond, nil
}

type resolvedRecipient struct {
	EmployeeID uint64
	Label      string
}

var allowedRecipientTargets = map[string]alertmodel.RecipientTargetKey{
	string(alertmodel.RecipientTargetSecurityTeam):    alertmodel.RecipientTargetSecurityTeam,
	string(alertmodel.RecipientTargetRDTeam):          alertmodel.RecipientTargetRDTeam,
	string(alertmodel.RecipientTargetEntityDept):      alertmodel.RecipientTargetEntityDept,
	string(alertmodel.RecipientTargetEntityOwner):     alertmodel.RecipientTargetEntityOwner,
	string(alertmodel.RecipientTargetEntityFixer):     alertmodel.RecipientTargetEntityFixer,
	string(alertmodel.RecipientTargetEntitySubmitter): alertmodel.RecipientTargetEntitySubmitter,
}

func (s *Service) ListRules(ctx context.Context) ([]alertmodel.LarkAlertRule, error) {
	if s.rules == nil {
		return []alertmodel.LarkAlertRule{}, nil
	}
	return s.rules.List(ctx)
}

func (s *Service) GetRule(ctx context.Context, id uint64) (*alertmodel.LarkAlertRule, error) {
	if s.rules == nil {
		return nil, errors.New("rules repository not configured")
	}
	return s.rules.Get(ctx, id)
}

func (s *Service) CreateRule(ctx context.Context, rule *alertmodel.LarkAlertRule) error {
	if s.rules == nil {
		return errors.New("rules repository not configured")
	}
	if rule.MaxMatchesPerRun <= 0 {
		rule.MaxMatchesPerRun = 50
	}
	if err := s.normalizeRule(rule); err != nil {
		return err
	}
	if err := s.validateRuleDeliveryConfig(ctx, rule); err != nil {
		return err
	}
	return s.rules.Create(ctx, rule)
}

func (s *Service) UpdateRule(ctx context.Context, rule *alertmodel.LarkAlertRule) error {
	if s.rules == nil {
		return errors.New("rules repository not configured")
	}
	if rule.MaxMatchesPerRun <= 0 {
		rule.MaxMatchesPerRun = 50
	}
	if err := s.normalizeRule(rule); err != nil {
		return err
	}
	if err := s.validateRuleDeliveryConfig(ctx, rule); err != nil {
		return err
	}
	return s.rules.Update(ctx, rule)
}

func (s *Service) DeleteRule(ctx context.Context, id uint64) error {
	if s.rules == nil {
		return errors.New("rules repository not configured")
	}
	return s.rules.Delete(ctx, id)
}

func (s *Service) ListRecentRuns(ctx context.Context, limit int) ([]alertmodel.LarkAlertRun, error) {
	if s.runs == nil {
		return []alertmodel.LarkAlertRun{}, nil
	}
	return s.runs.ListRecent(ctx, limit)
}

func (s *Service) ListDeliveriesByRun(ctx context.Context, runID uint64, limit int) ([]alertmodel.LarkAlertDelivery, error) {
	if s.deliveries == nil {
		return []alertmodel.LarkAlertDelivery{}, nil
	}
	return s.deliveries.ListByRun(ctx, runID, limit)
}

func (s *Service) GetEntityFields(ctx context.Context, entityType string) ([]FieldInfo, error) {
	if s.entityStore == nil {
		return []FieldInfo{}, nil
	}
	return s.entityStore.GetEntityFields(ctx, entityType)
}

func (s *Service) ValidateAndRenderTemplate(ctx context.Context, entityType string, entityID *uint64, cardTemplate string) (map[string]interface{}, error) {
	var entity interface{}
	if entityID != nil && *entityID > 0 {
		if s.entityStore == nil {
			return nil, errors.New("entity store not configured")
		}
		e, err := s.entityStore.FetchEntity(ctx, entityType, *entityID)
		if err != nil {
			return nil, err
		}
		entity = e
	}
	return s.renderCardTemplate(ctx, entityType, entity, cardTemplate)
}

func (s *Service) RunRuleNow(ctx context.Context, ruleID uint64) (*alertmodel.LarkAlertRun, error) {
	if s.rules == nil {
		return nil, errors.New("rules repository not configured")
	}
	rule, err := s.rules.Get(ctx, ruleID)
	if err != nil {
		return nil, err
	}
	return s.runRule(ctx, rule, alertmodel.TriggerSourceManual, "", nil)
}

func (s *Service) TestSend(ctx context.Context, employeeID *uint64, sendWebhook bool, cardTemplate string) (*alertmodel.LarkAlertRun, error) {
	rule := &alertmodel.LarkAlertRule{
		Name:                 "Test Send",
		Enabled:              true,
		EntityType:           "vulnerability",
		SendViaWebhook:       sendWebhook,
		SendViaApp:           employeeID != nil && *employeeID > 0,
		RecipientEmployeeIDs: nil,
		CardTemplate:         cardTemplate,
		MaxMatchesPerRun:     1,
	}
	if employeeID != nil && *employeeID > 0 {
		rule.RecipientEmployeeIDs = alertmodel.UInt64Array{*employeeID}
	}
	if err := s.validateDeliveryTargets(rule); err != nil {
		return nil, err
	}
	return s.runTestSend(ctx, rule)
}

func (s *Service) runTestSend(ctx context.Context, rule *alertmodel.LarkAlertRule) (*alertmodel.LarkAlertRun, error) {
	started := time.Now().UTC()
	run := &alertmodel.LarkAlertRun{
		TriggerSource:     alertmodel.TriggerSourceManual,
		TriggerEntityType: "test",
		Status:            alertmodel.RunStatusRunning,
		StartedAt:         started,
	}
	if s.runs != nil {
		_ = s.runs.Create(ctx, run)
	}
	defer func() {
		if s.runs != nil && run.ID != 0 {
			_ = s.runs.Update(ctx, run)
		}
	}()

	settings, err := s.getRawSettings(ctx)
	if err != nil {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = err.Error()
		s.finishRun(run, started, 0, 0, 0)
		return run, err
	}
	if !settings.Notifications.LarkAlerts {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = "lark alerts are disabled in platform settings"
		s.finishRun(run, started, 0, 0, 0)
		return run, errors.New(run.ErrorMessage)
	}

	card, err := s.renderCardTemplate(ctx, rule.EntityType, nil, rule.CardTemplate)
	if err != nil {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = err.Error()
		s.finishRun(run, started, 0, 0, 0)
		return run, err
	}

	var deliveriesTotal, deliveriesSuccess, deliveriesFailed int
	if rule.SendViaWebhook {
		deliveriesTotal++
		if s.sendWebhook(ctx, settings.Notifications.LarkWebhook, card, run, rule) {
			deliveriesSuccess++
		} else {
			deliveriesFailed++
		}
	}
	if rule.SendViaApp {
		for _, employeeID := range rule.RecipientEmployeeIDs {
			deliveriesTotal++
			if s.sendToEmployee(ctx, settings, employeeID, "test_send", card, run, rule) {
				deliveriesSuccess++
			} else {
				deliveriesFailed++
			}
		}
	}

	run.MatchedCount = 1
	run.DeliveriesTotal = deliveriesTotal
	run.DeliveriesSuccess = deliveriesSuccess
	run.DeliveriesFailed = deliveriesFailed
	if deliveriesFailed > 0 || deliveriesTotal == 0 {
		run.Status = alertmodel.RunStatusError
		if deliveriesTotal == 0 {
			run.ErrorMessage = "no delivery targets selected"
		} else {
			run.ErrorMessage = fmt.Sprintf("%d deliveries failed", deliveriesFailed)
		}
	} else {
		run.Status = alertmodel.RunStatusSuccess
	}
	s.finishRun(run, started, 1, deliveriesSuccess, deliveriesFailed)
	return run, nil
}

func (s *Service) RunForEvent(ctx context.Context, rule *alertmodel.LarkAlertRule, triggerEntityType string, triggerEntityID uint64) (*alertmodel.LarkAlertRun, error) {
	id := triggerEntityID
	return s.runRule(ctx, rule, alertmodel.TriggerSourceEvent, triggerEntityType, &id)
}

func (s *Service) RunForCron(ctx context.Context, rule *alertmodel.LarkAlertRule) (*alertmodel.LarkAlertRun, error) {
	return s.runRule(ctx, rule, alertmodel.TriggerSourceCron, "", nil)
}

func (s *Service) runRule(ctx context.Context, rule *alertmodel.LarkAlertRule, source alertmodel.TriggerSource, triggerEntityType string, triggerEntityID *uint64) (*alertmodel.LarkAlertRun, error) {
	started := time.Now().UTC()
	var ruleID *uint64
	if rule.ID != 0 {
		id := rule.ID
		ruleID = &id
	}
	run := &alertmodel.LarkAlertRun{
		RuleID:            ruleID,
		TriggerSource:     source,
		TriggerEntityType: triggerEntityType,
		TriggerEntityID:   triggerEntityID,
		Status:            alertmodel.RunStatusRunning,
		StartedAt:         started,
	}
	if s.runs != nil {
		_ = s.runs.Create(ctx, run)
	}

	defer func() {
		if s.runs != nil && run.ID != 0 {
			_ = s.runs.Update(ctx, run)
		}
	}()

	settings, err := s.getRawSettings(ctx)
	if err != nil {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = err.Error()
		s.finishRun(run, started, 0, 0, 0)
		return run, err
	}

	if !settings.Notifications.LarkAlerts {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = "lark alerts are disabled in platform settings"
		s.finishRun(run, started, 0, 0, 0)
		return run, errors.New(run.ErrorMessage)
	}

	matches, err := s.findMatches(ctx, rule, triggerEntityID)
	if err != nil {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = err.Error()
		s.finishRun(run, started, 0, 0, 0)
		return run, err
	}

	run.MatchedCount = len(matches)

	var deliveriesTotal, deliveriesSuccess, deliveriesFailed int
	for _, match := range matches {
		card, renderErr := s.renderCardTemplate(ctx, rule.EntityType, match, rule.CardTemplate)
		if renderErr != nil {
			s.recordScopeDelivery(ctx, run, rule, "card_render", alertmodel.DeliveryStatusError, renderErr.Error(), nil)
			deliveriesFailed++
			continue
		}

		if rule.SendViaWebhook {
			ok := s.sendWebhook(ctx, settings.Notifications.LarkWebhook, card, run, rule)
			deliveriesTotal++
			if ok {
				deliveriesSuccess++
			} else {
				deliveriesFailed++
			}
		}

		if rule.SendViaApp {
			recipients, blockedScopes, resolveErr := s.resolveEmployeeRecipients(ctx, rule, match)
			if resolveErr != nil {
				s.recordScopeDelivery(ctx, run, rule, "recipient_resolution", alertmodel.DeliveryStatusError, resolveErr.Error(), card)
				deliveriesTotal++
				deliveriesFailed++
			}
			for _, scope := range blockedScopes {
				s.recordScopeDelivery(ctx, run, rule, scope, alertmodel.DeliveryStatusError, "org sync freshness blocks org-aware fan-out for this target", card)
				deliveriesTotal++
				deliveriesFailed++
			}
			for _, recipient := range recipients {
				ok := s.sendToEmployee(ctx, settings, recipient.EmployeeID, recipient.Label, card, run, rule)
				deliveriesTotal++
				if ok {
					deliveriesSuccess++
				} else {
					deliveriesFailed++
				}
			}
			if len(recipients) == 0 && len(blockedScopes) == 0 && resolveErr == nil {
				s.recordScopeDelivery(ctx, run, rule, "recipient_resolution", alertmodel.DeliveryStatusError, "no employee recipients resolved", card)
				deliveriesTotal++
				deliveriesFailed++
			}
		}
	}

	run.DeliveriesTotal = deliveriesTotal
	run.DeliveriesSuccess = deliveriesSuccess
	run.DeliveriesFailed = deliveriesFailed
	if deliveriesFailed > 0 {
		run.Status = alertmodel.RunStatusError
		run.ErrorMessage = fmt.Sprintf("%d deliveries failed", deliveriesFailed)
	} else {
		run.Status = alertmodel.RunStatusSuccess
	}

	if s.rules != nil && rule.ID != 0 {
		now := time.Now().UTC()
		rule.LastRunAt = &now
		_ = s.rules.Update(ctx, rule)
	}

	s.finishRun(run, started, len(matches), deliveriesSuccess, deliveriesFailed)
	return run, nil
}

func (s *Service) finishRun(run *alertmodel.LarkAlertRun, started time.Time, matched int, ok int, failed int) {
	finished := time.Now().UTC()
	run.FinishedAt = &finished
	run.DurationMs = uint64(time.Since(started).Milliseconds())
	run.MatchedCount = matched
	run.DeliveriesSuccess = ok
	run.DeliveriesFailed = failed
	run.DeliveriesTotal = ok + failed
}

func (s *Service) getRawSettings(ctx context.Context) (*SettingsSnapshot, error) {
	if s.settingsSvc == nil {
		return nil, errors.New("settings service not configured")
	}
	method := reflect.ValueOf(s.settingsSvc).MethodByName("Get")
	if !method.IsValid() {
		return nil, errors.New("settings service not configured")
	}
	results := method.Call([]reflect.Value{reflect.ValueOf(ctx)})
	if len(results) != 2 {
		return nil, errors.New("settings service returned unexpected result")
	}
	if err, ok := results[1].Interface().(error); ok && err != nil {
		return nil, err
	}
	if !results[0].IsValid() || (results[0].Kind() == reflect.Pointer && results[0].IsNil()) {
		return nil, errors.New("settings service returned nil settings")
	}
	data, err := json.Marshal(results[0].Interface())
	if err != nil {
		return nil, err
	}
	var out SettingsSnapshot
	if err := json.Unmarshal(data, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func (s *Service) findMatches(ctx context.Context, rule *alertmodel.LarkAlertRule, entityID *uint64) ([]map[string]interface{}, error) {
	if s.entityStore == nil {
		return nil, errors.New("entity store not configured")
	}
	limit := rule.MaxMatchesPerRun
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	return s.entityStore.FindMatches(ctx, rule.EntityType, entityID, rule.Conditions, limit)
}

func (s *Service) normalizeRule(rule *alertmodel.LarkAlertRule) error {
	if rule == nil {
		return errors.New("rule is required")
	}
	rule.RecipientEmployeeIDs = normalizeEmployeeIDs(rule.RecipientEmployeeIDs)
	targets, err := normalizeRecipientTargetKeys(rule.RecipientTargetKeys)
	if err != nil {
		return err
	}
	rule.RecipientTargetKeys = targets
	if err := s.validateDeliveryTargets(rule); err != nil {
		return err
	}
	return nil
}

func (s *Service) validateDeliveryTargets(rule *alertmodel.LarkAlertRule) error {
	if rule == nil {
		return errors.New("rule is required")
	}
	if !rule.SendViaWebhook && !rule.SendViaApp {
		return errors.New("at least one delivery path must be enabled")
	}
	if rule.SendViaApp && len(rule.RecipientEmployeeIDs) == 0 && len(rule.RecipientTargetKeys) == 0 {
		return errors.New("send via app requires at least one employee recipient or org-aware recipient target")
	}
	return nil
}

func (s *Service) validateRuleDeliveryConfig(ctx context.Context, rule *alertmodel.LarkAlertRule) error {
	if rule == nil {
		return errors.New("rule is required")
	}
	if !rule.SendViaWebhook && !rule.SendViaApp {
		return nil
	}
	settings, err := s.getRawSettings(ctx)
	if err != nil {
		return err
	}
	if rule.SendViaWebhook && strings.TrimSpace(settings.Notifications.LarkWebhook) == "" {
		return errors.New("send via webhook requires a configured lark webhook in platform settings")
	}
	if rule.SendViaApp {
		appID := strings.TrimSpace(settings.Notifications.LarkAppID)
		appSecret := strings.TrimSpace(settings.Notifications.LarkAppSecret)
		if appID == "" || appSecret == "" {
			return errors.New("send via app requires configured lark app credentials in platform settings")
		}
	}
	return nil
}

func normalizeEmployeeIDs(ids alertmodel.UInt64Array) alertmodel.UInt64Array {
	if len(ids) == 0 {
		return alertmodel.UInt64Array{}
	}
	seen := make(map[uint64]struct{}, len(ids))
	out := make(alertmodel.UInt64Array, 0, len(ids))
	for _, id := range ids {
		if id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func normalizeRecipientTargetKeys(keys alertmodel.StringArray) (alertmodel.StringArray, error) {
	if len(keys) == 0 {
		return alertmodel.StringArray{}, nil
	}
	seen := make(map[string]struct{}, len(keys))
	out := make(alertmodel.StringArray, 0, len(keys))
	for _, raw := range keys {
		key := strings.ToLower(strings.TrimSpace(raw))
		if key == "" {
			continue
		}
		if _, ok := allowedRecipientTargets[key]; !ok {
			return nil, fmt.Errorf("unsupported recipient target: %s", raw)
		}
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, key)
	}
	slices.Sort(out)
	return out, nil
}

func containsRecipientTarget(keys alertmodel.StringArray, target string) bool {
	target = strings.ToLower(strings.TrimSpace(target))
	for _, raw := range keys {
		if strings.ToLower(strings.TrimSpace(raw)) == target {
			return true
		}
	}
	return false
}

func (s *Service) resolveEmployeeRecipients(ctx context.Context, rule *alertmodel.LarkAlertRule, entity map[string]interface{}) ([]resolvedRecipient, []string, error) {
	seen := make(map[uint64]resolvedRecipient, len(rule.RecipientEmployeeIDs))
	for _, employeeID := range normalizeEmployeeIDs(rule.RecipientEmployeeIDs) {
		seen[employeeID] = resolvedRecipient{
			EmployeeID: employeeID,
			Label:      fmt.Sprintf("employee:%d", employeeID),
		}
	}

	targets, err := normalizeRecipientTargetKeys(rule.RecipientTargetKeys)
	if err != nil {
		return nil, nil, err
	}
	if len(targets) == 0 {
		return mapRecipients(seen), nil, nil
	}
	if s.employeeLookup == nil {
		return mapRecipients(seen), nil, errors.New("employee lookup not configured")
	}

	orgSync := s.currentOrgSyncStatus()
	blocked := make([]string, 0, len(targets))
	for _, target := range targets {
		if orgSync.blocksTarget(target) {
			blocked = append(blocked, target)
			continue
		}
		recipients, err := s.resolveRecipientsForTarget(ctx, target, entity)
		if err != nil {
			return mapRecipients(seen), blocked, fmt.Errorf("resolve %s recipients: %w", target, err)
		}
		for _, recipient := range recipients {
			if recipient.EmployeeID == 0 {
				continue
			}
			if existing, ok := seen[recipient.EmployeeID]; ok {
				if recipient.Label != "" && !strings.Contains(existing.Label, recipient.Label) {
					existing.Label = existing.Label + "," + recipient.Label
					seen[recipient.EmployeeID] = existing
				}
				continue
			}
			seen[recipient.EmployeeID] = recipient
		}
	}
	slices.Sort(blocked)
	if s.logger != nil {
		labels := make([]string, 0, len(seen))
		employeeIDs := make([]uint64, 0, len(seen))
		for _, recipient := range seen {
			employeeIDs = append(employeeIDs, recipient.EmployeeID)
			labels = append(labels, recipient.Label)
		}
		slices.Sort(employeeIDs)
		slices.Sort(labels)
		s.logger.Info(
			"resolved alert recipients",
			"rule_id", rule.ID,
			"entity_type", rule.EntityType,
			"explicit_employee_count", len(rule.RecipientEmployeeIDs),
			"target_keys", []string(targets),
			"resolved_employee_count", len(seen),
			"resolved_employee_ids", employeeIDs,
			"resolved_labels", labels,
			"blocked_targets", blocked,
			"org_sync_freshness_state", orgSync.FreshnessState,
			"org_sync_trust_mode", orgSync.TrustMode,
		)
	}
	return mapRecipients(seen), blocked, nil
}

type orgSyncStatusSnapshot struct {
	FreshnessState string
	TrustMode      string
	BlockingScopes []string
}

func (s *Service) currentOrgSyncStatus() orgSyncStatusSnapshot {
	if s.orgSyncStatus == nil {
		return orgSyncStatusSnapshot{
			FreshnessState: "unknown",
			TrustMode:      "blocked",
			BlockingScopes: []string{"team_fanout", "department_fanout"},
		}
	}
	status := s.orgSyncStatus()
	return orgSyncStatusSnapshot{
		FreshnessState: status.FreshnessState,
		TrustMode:      status.TrustMode,
		BlockingScopes: append([]string(nil), status.BlockingScopes...),
	}
}

func (s orgSyncStatusSnapshot) blocksTarget(target string) bool {
	switch target {
	case string(alertmodel.RecipientTargetSecurityTeam), string(alertmodel.RecipientTargetRDTeam):
		return slices.Contains(s.BlockingScopes, "team_fanout")
	case string(alertmodel.RecipientTargetEntityDept):
		return slices.Contains(s.BlockingScopes, "department_fanout")
	default:
		return false
	}
}

func (s *Service) resolveRecipientsForTarget(ctx context.Context, target string, entity map[string]interface{}) ([]resolvedRecipient, error) {
	switch target {
	case string(alertmodel.RecipientTargetSecurityTeam):
		return s.resolveEmployeesByPredicate(ctx, target, matchesSecurityTeam)
	case string(alertmodel.RecipientTargetRDTeam):
		return s.resolveEmployeesByPredicate(ctx, target, matchesRDTeam)
	case string(alertmodel.RecipientTargetEntityDept):
		departmentID := entityDepartmentID(entity)
		if departmentID == "" {
			return nil, errors.New("entity has no department_id or owner_department_id")
		}
		employees, err := s.employeeLookup.ListByDepartment(ctx, departmentID)
		if err != nil {
			return nil, err
		}
		if s.logger != nil {
			rawType := ""
			if raw, ok := entity["department_id"]; ok && raw != nil {
				rawType = fmt.Sprintf("%T", raw)
			}
			s.logger.Info(
				"resolved entity department recipients",
				"department_id", departmentID,
				"department_raw_type", rawType,
				"resolved_employee_count", len(employees),
			)
		}
		return employeesToRecipients(employees, target), nil
	case string(alertmodel.RecipientTargetEntityOwner):
		ownerID := entityEmployeeID(entity, "owner_employee_id")
		if ownerID == 0 {
			return nil, errors.New("entity has no owner_employee_id")
		}
		return []resolvedRecipient{{EmployeeID: ownerID, Label: target}}, nil
	case string(alertmodel.RecipientTargetEntityFixer):
		fixerID := entityEmployeeID(entity, "fixer_employee_id")
		if fixerID == 0 {
			return nil, nil
		}
		return []resolvedRecipient{{EmployeeID: fixerID, Label: target}}, nil
	case string(alertmodel.RecipientTargetEntitySubmitter):
		submitterUserID := entityEmployeeID(entity, "submitter_user_id")
		if submitterUserID == 0 {
			return nil, errors.New("entity has no submitter_user_id")
		}
		if s.submitterLookup == nil {
			return nil, errors.New("submitter lookup not configured")
		}
		employeeID, err := s.submitterLookup.FindEmployeeIDByUserID(ctx, submitterUserID)
		if err != nil {
			return nil, fmt.Errorf("resolve submitter employee: %w", err)
		}
		if employeeID == 0 {
			return nil, fmt.Errorf("user %d has no employee_id", submitterUserID)
		}
		return []resolvedRecipient{{EmployeeID: employeeID, Label: target}}, nil
	default:
		return nil, fmt.Errorf("unsupported recipient target: %s", target)
	}
}

func (s *Service) resolveEmployeesByPredicate(ctx context.Context, label string, predicate func(*directory.Employee) bool) ([]resolvedRecipient, error) {
	employees, err := s.employeeLookup.ListActive(ctx)
	if err != nil {
		return nil, err
	}
	recipients := make([]resolvedRecipient, 0, len(employees))
	for i := range employees {
		if !predicate(&employees[i]) {
			continue
		}
		recipients = append(recipients, resolvedRecipient{
			EmployeeID: employees[i].ID,
			Label:      label,
		})
	}
	return recipients, nil
}

func employeesToRecipients(employees []directory.Employee, label string) []resolvedRecipient {
	recipients := make([]resolvedRecipient, 0, len(employees))
	for i := range employees {
		if employees[i].ID == 0 {
			continue
		}
		recipients = append(recipients, resolvedRecipient{
			EmployeeID: employees[i].ID,
			Label:      label,
		})
	}
	return recipients
}

func mapRecipients(items map[uint64]resolvedRecipient) []resolvedRecipient {
	recipients := make([]resolvedRecipient, 0, len(items))
	for _, recipient := range items {
		recipients = append(recipients, recipient)
	}
	slices.SortFunc(recipients, func(a, b resolvedRecipient) int {
		switch {
		case a.EmployeeID < b.EmployeeID:
			return -1
		case a.EmployeeID > b.EmployeeID:
			return 1
		default:
			return 0
		}
	})
	return recipients
}

func employeeMatchHaystack(emp *directory.Employee) string {
	parts := []string{emp.Name, emp.Email, emp.JobTitle, emp.DepartmentName}
	return strings.ToLower(strings.Join(parts, " "))
}

func matchesSecurityTeam(emp *directory.Employee) bool {
	haystack := employeeMatchHaystack(emp)
	for _, keyword := range []string{"security", "secops", "infosec", "soc", "cyber"} {
		if strings.Contains(haystack, keyword) {
			return true
		}
	}
	return false
}

func matchesRDTeam(emp *directory.Employee) bool {
	haystack := employeeMatchHaystack(emp)
	for _, keyword := range []string{"engineering", "engineer", "developer", "development", "platform", "backend", "frontend", "devops", "sre", "qa", "test", "研发", "开发", "技术"} {
		if strings.Contains(haystack, keyword) {
			return true
		}
	}
	return false
}

func entityDepartmentID(entity map[string]interface{}) string {
	if entity == nil {
		return ""
	}
	for _, key := range []string{"department_id", "owner_department_id"} {
		value, ok := entity[key]
		if !ok || value == nil {
			continue
		}
		switch typed := value.(type) {
		case string:
			text := strings.TrimSpace(typed)
			if text != "" {
				return text
			}
			continue
		case *string:
			if typed != nil {
				text := strings.TrimSpace(*typed)
				if text != "" {
					return text
				}
			}
			continue
		}
		text := strings.TrimSpace(fmt.Sprint(value))
		if text != "" && text != "<nil>" {
			return text
		}
	}
	return ""
}

func entityEmployeeID(entity map[string]interface{}, keys ...string) uint64 {
	if entity == nil {
		return 0
	}
	for _, key := range keys {
		value, ok := entity[key]
		if !ok || value == nil {
			continue
		}
		switch typed := value.(type) {
		case *uint64:
			if typed != nil && *typed > 0 {
				return *typed
			}
		case *int:
			if typed != nil && *typed > 0 {
				return uint64(*typed)
			}
		case *int64:
			if typed != nil && *typed > 0 {
				return uint64(*typed)
			}
		case *float64:
			if typed != nil && *typed > 0 {
				return uint64(*typed)
			}
		case float64:
			if typed > 0 {
				return uint64(typed)
			}
		case int:
			if typed > 0 {
				return uint64(typed)
			}
		case int64:
			if typed > 0 {
				return uint64(typed)
			}
		case uint64:
			if typed > 0 {
				return typed
			}
		case json.Number:
			if parsed, err := typed.Int64(); err == nil && parsed > 0 {
				return uint64(parsed)
			}
		case *json.Number:
			if typed != nil {
				if parsed, err := typed.Int64(); err == nil && parsed > 0 {
					return uint64(parsed)
				}
			}
		default:
			text := strings.TrimSpace(fmt.Sprint(value))
			if text == "" || text == "<nil>" {
				continue
			}
			var parsed uint64
			if _, err := fmt.Sscan(text, &parsed); err == nil && parsed > 0 {
				return parsed
			}
		}
	}
	return 0
}

func entityString(entity map[string]interface{}, keys ...string) string {
	if entity == nil {
		return ""
	}
	for _, key := range keys {
		value, ok := entity[key]
		if !ok || value == nil {
			continue
		}
		switch typed := value.(type) {
		case string:
			if text := strings.TrimSpace(typed); text != "" {
				return text
			}
		case *string:
			if typed != nil {
				if text := strings.TrimSpace(*typed); text != "" {
					return text
				}
			}
		case json.Number:
			if text := strings.TrimSpace(typed.String()); text != "" {
				return text
			}
		default:
			text := strings.TrimSpace(fmt.Sprint(value))
			if text != "" && text != "<nil>" {
				return text
			}
		}
	}
	return ""
}

func (s *Service) enrichCardTemplateEntity(ctx context.Context, entityType string, entity map[string]interface{}) map[string]interface{} {
	if entity == nil {
		return map[string]interface{}{}
	}
	if entityType != "vulnerability" {
		return entity
	}

	if entityString(entity, "public_id") == "" {
		if fallbackID := entityString(entity, "id"); fallbackID != "" {
			entity["public_id"] = fallbackID
		}
	}
	entity["owner_label"] = s.employeeTemplateLabel(ctx, entityEmployeeID(entity, "owner_employee_id"), "Unassigned")
	entity["fixer_label"] = s.employeeTemplateLabel(ctx, entityEmployeeID(entity, "fixer_employee_id"), "Unassigned")
	entity["submitter_label"] = s.submitterTemplateLabel(ctx, entityEmployeeID(entity, "submitter_user_id"))
	entity["vulnerability_url"] = vulnerabilityTemplateURL(entity)

	return entity
}

func (s *Service) submitterTemplateLabel(ctx context.Context, submitterUserID uint64) string {
	if submitterUserID == 0 {
		return "Unknown"
	}
	if s == nil || s.submitterLookup == nil {
		return fmt.Sprintf("User %d", submitterUserID)
	}
	employeeID, err := s.submitterLookup.FindEmployeeIDByUserID(ctx, submitterUserID)
	if err != nil || employeeID == 0 {
		return fmt.Sprintf("User %d", submitterUserID)
	}
	return s.employeeTemplateLabel(ctx, employeeID, fmt.Sprintf("User %d", submitterUserID))
}

func (s *Service) employeeTemplateLabel(ctx context.Context, employeeID uint64, fallback string) string {
	if employeeID == 0 {
		return fallback
	}
	if s == nil || s.employeeLookup == nil {
		return fmt.Sprintf("Employee %d", employeeID)
	}
	emp, err := s.employeeLookup.FindByID(ctx, employeeID)
	if err != nil || emp == nil {
		return fmt.Sprintf("Employee %d", employeeID)
	}
	return employeeDisplayLabel(emp)
}

func employeeDisplayLabel(emp *directory.Employee) string {
	if emp == nil {
		return "Unknown"
	}
	name := strings.TrimSpace(emp.Name)
	email := strings.TrimSpace(emp.Email)
	switch {
	case name != "" && email != "":
		return fmt.Sprintf("%s (%s)", name, email)
	case name != "":
		return name
	case email != "":
		return email
	case strings.TrimSpace(emp.LarkUserID) != "":
		return strings.TrimSpace(emp.LarkUserID)
	case emp.ID > 0:
		return fmt.Sprintf("Employee %d", emp.ID)
	default:
		return "Unknown"
	}
}

func vulnerabilityTemplateURL(entity map[string]interface{}) string {
	publicID := entityString(entity, "public_id")
	if publicID == "" {
		publicID = entityString(entity, "id")
	}
	if publicID == "" {
		return "/dashboard/vulnerabilities"
	}
	path := "/dashboard/vulnerabilities/" + publicID
	for _, key := range []string{"SECURITY_CENTER_PUBLIC_BASE_URL", "SC_PUBLIC_BASE_URL", "NEXT_PUBLIC_APP_URL"} {
		base := strings.TrimSpace(os.Getenv(key))
		if base != "" {
			return strings.TrimRight(base, "/") + path
		}
	}
	return path
}

func (s *Service) renderCardTemplate(ctx context.Context, entityType string, entity interface{}, cardTemplate string) (map[string]interface{}, error) {
	if strings.TrimSpace(cardTemplate) == "" {
		return nil, errors.New("card_template is required")
	}
	entityMap := map[string]interface{}{}
	if entity != nil {
		m, err := toJSONMap(entity)
		if err != nil {
			return nil, err
		}
		entityMap = m
	}
	templateCtx := map[string]interface{}{
		"entity_type": entityType,
		"entity":      s.enrichCardTemplateEntity(ctx, entityType, entityMap),
	}

	tpl, err := template.New("card").Option("missingkey=zero").Parse(cardTemplate)
	if err != nil {
		return nil, err
	}
	var buf bytes.Buffer
	if err := tpl.Execute(&buf, templateCtx); err != nil {
		return nil, err
	}

	var card map[string]interface{}
	if err := json.Unmarshal(buf.Bytes(), &card); err != nil {
		return nil, fmt.Errorf("rendered card is not valid JSON: %w", err)
	}
	return card, nil
}

func toJSONMap(v interface{}) (map[string]interface{}, error) {
	raw, err := json.Marshal(v)
	if err != nil {
		return nil, err
	}
	var out map[string]interface{}
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (s *Service) sendWebhook(ctx context.Context, webhookURL string, card map[string]interface{}, run *alertmodel.LarkAlertRun, rule *alertmodel.LarkAlertRule) bool {
	webhookURL = strings.TrimSpace(webhookURL)
	if webhookURL == "" {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeWebhook, "webhook", nil, hashWebhook(""), alertmodel.DeliveryStatusError, nil, "lark webhook not configured", "", card)
		return false
	}
	payload := map[string]interface{}{"msg_type": "interactive", "card": card}
	body, _ := json.Marshal(payload)

	req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewReader(body))
	if err != nil {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeWebhook, "webhook", nil, hashWebhook(webhookURL), alertmodel.DeliveryStatusError, nil, err.Error(), "", card)
		return false
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := s.httpClient.Do(req)
	if err != nil {
		if s.logger != nil {
			s.logger.Warn("alert webhook delivery failed", "run_id", run.ID, "rule_id", run.RuleID, "error", err)
		}
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeWebhook, "webhook", nil, hashWebhook(webhookURL), alertmodel.DeliveryStatusError, nil, err.Error(), "", card)
		return false
	}
	defer resp.Body.Close()

	status := alertmodel.DeliveryStatusSuccess
	var codePtr *int
	code := resp.StatusCode
	codePtr = &code
	respBody := ""
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		status = alertmodel.DeliveryStatusError
		if data, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096)); readErr == nil {
			respBody = string(data)
		}
	}
	if s.logger != nil {
		if status == alertmodel.DeliveryStatusSuccess {
			s.logger.Info("alert webhook delivery succeeded", "run_id", run.ID, "rule_id", run.RuleID, "status_code", resp.StatusCode)
		} else {
			s.logger.Warn("alert webhook delivery returned error", "run_id", run.ID, "rule_id", run.RuleID, "status_code", resp.StatusCode, "response_body", respBody)
		}
	}
	s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeWebhook, "webhook", nil, hashWebhook(webhookURL), status, codePtr, "", respBody, card)
	return status == alertmodel.DeliveryStatusSuccess
}

func (s *Service) sendToEmployee(ctx context.Context, settings *SettingsSnapshot, employeeID uint64, recipientLabel string, card map[string]interface{}, run *alertmodel.LarkAlertRun, rule *alertmodel.LarkAlertRule) bool {
	if s.employeeLookup == nil {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusError, nil, "employee lookup not configured", "", card)
		return false
	}
	emp, err := s.employeeLookup.FindByID(ctx, employeeID)
	if err != nil {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusError, nil, err.Error(), "", card)
		return false
	}

	appID := strings.TrimSpace(settings.Notifications.LarkAppID)
	appSecret := strings.TrimSpace(settings.Notifications.LarkAppSecret)
	if appID == "" || appSecret == "" {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusError, nil, "lark app credentials not configured", "", card)
		return false
	}

	receiveIDType := normalizeReceiveIDType(settings.Notifications.LarkReceiveIDType)
	receiveID, err := employeeReceiveID(emp, receiveIDType)
	if err != nil {
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusError, nil, err.Error(), "", card)
		return false
	}
	if s.logger != nil {
		s.logger.Info(
			"alert employee delivery attempt",
			"run_id", run.ID,
			"rule_id", run.RuleID,
			"employee_id", employeeID,
			"recipient_label", recipientLabel,
			"receive_id_type", receiveIDType,
		)
	}

	client := lark.NewClient(appID, appSecret)
	if err := client.SendInteractiveCard(ctx, receiveIDType, receiveID, card); err != nil {
		deliveryErr := explainLarkEmployeeDeliveryError(receiveIDType, err)
		if s.logger != nil {
			s.logger.Warn("alert employee delivery failed", "run_id", run.ID, "employee_id", employeeID, "recipient_label", recipientLabel, "error", deliveryErr)
		}
		s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusError, nil, deliveryErr.Error(), "", card)
		return false
	}

	if s.logger != nil {
		s.logger.Info("alert employee delivery succeeded", "run_id", run.ID, "employee_id", employeeID, "recipient_label", recipientLabel)
	}
	s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeEmployee, recipientLabel, &employeeID, "", alertmodel.DeliveryStatusSuccess, nil, "", "", card)
	return true
}

func employeeReceiveID(emp *directory.Employee, receiveIDType string) (string, error) {
	switch normalizeReceiveIDType(receiveIDType) {
	case "open_id":
		if strings.TrimSpace(emp.LarkUserID) == "" {
			return "", errors.New("employee is missing lark_user_id")
		}
		return strings.TrimSpace(emp.LarkUserID), nil
	case "email":
		if strings.TrimSpace(emp.Email) == "" {
			return "", errors.New("employee is missing email")
		}
		return strings.TrimSpace(emp.Email), nil
	default:
		return "", fmt.Errorf("unsupported receive_id_type: %s", receiveIDType)
	}
}

func normalizeReceiveIDType(receiveIDType string) string {
	switch strings.ToLower(strings.TrimSpace(receiveIDType)) {
	case "", "user_id", "open_id":
		return "open_id"
	case "email":
		return "email"
	default:
		return "open_id"
	}
}

func explainLarkEmployeeDeliveryError(receiveIDType string, err error) error {
	if err == nil {
		return nil
	}
	message := err.Error()
	if normalizeReceiveIDType(receiveIDType) == "open_id" && strings.Contains(strings.ToLower(message), "open_id cross app") {
		return fmt.Errorf("Feishu rejected the employee open_id because it belongs to a different app. When lark_receive_id_type is open_id, alert delivery must use the same Feishu app that performed Org Sync, or switch lark_receive_id_type to email. original error: %w", err)
	}
	return err
}

func trimRecipientLabel(label string) string {
	label = strings.TrimSpace(label)
	if len(label) <= 64 {
		return label
	}
	return label[:64]
}

func (s *Service) recordDelivery(
	ctx context.Context,
	run *alertmodel.LarkAlertRun,
	rule *alertmodel.LarkAlertRule,
	recipientType alertmodel.RecipientType,
	recipientLabel string,
	employeeID *uint64,
	webhookHash string,
	status alertmodel.DeliveryStatus,
	responseCode *int,
	errMsg string,
	respBody string,
	card map[string]interface{},
) {
	if s.deliveries == nil || run == nil || run.ID == 0 {
		return
	}
	var cardPreview coremodel.JSONB
	if card != nil {
		cardPreview = coremodel.JSONB(card)
	}
	d := &alertmodel.LarkAlertDelivery{
		RunID:          run.ID,
		RuleID:         run.RuleID,
		RecipientType:  recipientType,
		RecipientLabel: trimRecipientLabel(recipientLabel),
		EmployeeID:     employeeID,
		WebhookURLHash: webhookHash,
		Status:         status,
		SentAt:         time.Now().UTC(),
		ResponseCode:   responseCode,
		ErrorMessage:   errMsg,
		ResponseBody:   respBody,
		CardPreview:    cardPreview,
	}
	if err := s.deliveries.Create(ctx, d); err != nil {
		if s.logger != nil {
			s.logger.Error(
				"alert delivery persistence failed",
				"run_id", run.ID,
				"rule_id", run.RuleID,
				"recipient_type", recipientType,
				"recipient_label", d.RecipientLabel,
				"employee_id", employeeID,
				"status", status,
				"error", err,
				"retry_without_card_preview", card != nil,
			)
		}
		if card == nil {
			return
		}

		d.CardPreview = nil
		if retryErr := s.deliveries.Create(ctx, d); retryErr != nil && s.logger != nil {
			s.logger.Error(
				"alert delivery persistence retry failed",
				"run_id", run.ID,
				"rule_id", run.RuleID,
				"recipient_type", recipientType,
				"recipient_label", d.RecipientLabel,
				"employee_id", employeeID,
				"status", status,
				"error", retryErr,
			)
		}
	}
}

func (s *Service) recordScopeDelivery(ctx context.Context, run *alertmodel.LarkAlertRun, rule *alertmodel.LarkAlertRule, recipientLabel string, status alertmodel.DeliveryStatus, errMsg string, card map[string]interface{}) {
	s.recordDelivery(ctx, run, rule, alertmodel.RecipientTypeScope, recipientLabel, nil, "", status, nil, errMsg, "", card)
}

func hashWebhook(url string) string {
	if strings.TrimSpace(url) == "" {
		return ""
	}
	sum := sha256.Sum256([]byte(url))
	return hex.EncodeToString(sum[:])
}

func asDB(v interface{}) *gorm.DB {
	if db, ok := v.(*gorm.DB); ok {
		return db
	}
	return nil
}
