package store

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"math"
	"slices"
	"strings"

	"github.com/google/uuid"
	"github.com/prometheus/alertmanager/pkg/labels"

	"github.com/grafana/grafana/pkg/util/xorm"

	"github.com/grafana/grafana/pkg/apimachinery/identity"
	"github.com/grafana/grafana/pkg/infra/db"
	"github.com/grafana/grafana/pkg/services/accesscontrol"
	"github.com/grafana/grafana/pkg/services/dashboards"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/services/folder"
	ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
	"github.com/grafana/grafana/pkg/services/org"
	"github.com/grafana/grafana/pkg/services/sqlstore"
	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
	"github.com/grafana/grafana/pkg/services/store/entity"
	"github.com/grafana/grafana/pkg/util"
)

const (
	// AlertRuleMaxTitleLength is the maximum length of the alert rule title
	AlertRuleMaxTitleLength = 190

	// AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name
	AlertRuleMaxRuleGroupNameLength = 190
)

var (
	ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking")
)

// DeleteAlertRulesByUID is a handler for deleting an alert rule.
func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, user *ngmodels.UserUID, permanently bool, ruleUID ...string) error {
	if len(ruleUID) == 0 {
		return nil
	}
	logger := st.Logger.New("org_id", orgID, "rule_uids", ruleUID)
	return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
		rows, err := sess.Table(alertRule{}).Where("org_id = ?", orgID).In("uid", ruleUID).Delete(alertRule{})
		if err != nil {
			return err
		}
		logger.Debug("Deleted alert rules", "count", rows)
		if rows > 0 {
			keys := make([]ngmodels.AlertRuleKey, 0, len(ruleUID))
			for _, uid := range ruleUID {
				keys = append(keys, ngmodels.AlertRuleKey{OrgID: orgID, UID: uid})
			}
			_ = st.Bus.Publish(ctx, &RuleChangeEvent{
				RuleKeys: keys,
			})
		}

		rows, err = sess.Table("alert_instance").Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
		if err != nil {
			return err
		}
		logger.Debug("Deleted alert instances", "count", rows)

		rows, err = sess.Table("alert_rule_state").Where("org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
		if err != nil {
			return err
		}
		logger.Debug("Deleted alert rule state", "count", rows)

		var versions []alertRuleVersion
		//nolint:staticcheck // not yet migrated to OpenFeature
		if st.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertRuleRestore) && st.Cfg.DeletedRuleRetention > 0 && !permanently { // save deleted version only if retention is greater than 0
			versions, err = st.getLatestVersionOfRulesByUID(ctx, orgID, ruleUID)
			if err != nil {
				logger.Error("Failed to get latest version of deleted alert rules. The recovery will not be possible", "error", err)
			}
			for idx := range versions {
				version := &versions[idx]
				version.ID = 0
				version.RuleUID = ""
				version.Created = TimeNow()
				version.CreatedBy = nil
				if user != nil {
					version.CreatedBy = util.Pointer(string(*user))
				}
			}
		}

		rows, err = sess.Table(alertRuleVersion{}).Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
		if err != nil {
			return err
		}
		logger.Debug("Deleted alert rule versions", "count", rows)

		if len(versions) > 0 {
			_, err = sess.BulkInsert(alertRuleVersion{}, versions, sqlstore.NativeSettingsForDialect(st.SQLStore.GetDialect()))
			if err != nil {
				return fmt.Errorf("failed to persist deleted rule for recovery: %w", err)
			}
			logger.Debug("Inserted alert rule versions for recovery", "count", len(versions))
		}
		return nil
	})
}

func (st DBstore) getLatestVersionOfRulesByUID(ctx context.Context, orgID int64, ruleUIDs []string) ([]alertRuleVersion, error) {
	var result []alertRuleVersion
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		args, in := getINSubQueryArgs(ruleUIDs)
		// take only the latest versions of each rule by GUID
		rows, err := sess.SQL(fmt.Sprintf(`
		SELECT v1.* FROM alert_rule_version AS v1
			INNER JOIN (
			    SELECT rule_guid, MAX(id) AS id
			    FROM alert_rule_version
			    WHERE rule_org_id = ?
			      AND rule_uid IN (%s)
			    GROUP BY rule_guid
			) AS v2 ON v1.rule_guid = v2.rule_guid AND v1.id = v2.id
		`, strings.Join(in, ",")), append([]any{orgID}, args...)...).Rows(new(alertRuleVersion))

		if err != nil {
			return err
		}
		result = make([]alertRuleVersion, 0, len(ruleUIDs))
		for rows.Next() {
			rule := new(alertRuleVersion)
			err = rows.Scan(rule)
			if err != nil {
				st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "getLatestVersionOfRulesByUID", "error", err)
				continue
			}
			result = append(result, *rule)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return result, nil
}

// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace. Returns all rules that belong to the namespaces
func (st DBstore) IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error) {
	var keys []ngmodels.AlertRuleKeyWithVersion
	err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
		now := TimeNow()
		namespaceUIDsArgs, in := getINSubQueryArgs(namespaceUIDs)
		sql := fmt.Sprintf(
			"UPDATE alert_rule SET version = version + 1, updated = ? WHERE org_id = ? AND namespace_uid IN (%s)",
			strings.Join(in, ","),
		)
		args := make([]interface{}, 0, 3+len(namespaceUIDsArgs))
		args = append(args, sql, now, orgID)
		args = append(args, namespaceUIDsArgs...)

		_, err := sess.Exec(args...)
		if err != nil {
			return err
		}

		return sess.Table(alertRule{}).Where("org_id = ?", orgID).In("namespace_uid", namespaceUIDs).Find(&keys)
	})
	return keys, err
}

// getFolderFullpaths fetches fullpaths for multiple folders using a background user.
// Returns a map of folder UID -> fullpath, or nil if FolderService is not configured.
func (st DBstore) getFolderFullpaths(ctx context.Context, orgID int64, folderUIDs []string) (map[string]string, error) {
	if st.FolderService == nil {
		return nil, fmt.Errorf("folder service is not configured")
	}
	bgUser := accesscontrol.BackgroundUser("ngalert", orgID, org.RoleAdmin, []accesscontrol.Permission{
		{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
	})
	folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{
		OrgID:        orgID,
		UIDs:         folderUIDs,
		WithFullpath: true,
		SignedInUser: bgUser,
	})
	if err != nil {
		return nil, err
	}
	result := make(map[string]string, len(folders))
	for _, f := range folders {
		result[f.UID] = f.Fullpath
	}
	return result, nil
}

// UpdateFolderFullpathsForFolders updates the folder_fullpath column for all alert rules
// in the specified folders using the K8s folder service.
func (st DBstore) UpdateFolderFullpathsForFolders(ctx context.Context, orgID int64, folderUIDs []string) error {
	if len(folderUIDs) == 0 {
		return nil
	}

	logger := st.Logger.New("org_id", orgID, "folder_uid_count", len(folderUIDs))

	folderPaths, err := st.getFolderFullpaths(ctx, orgID, folderUIDs)
	if err != nil {
		logger.Error("Failed to fetch folders from folder service", "error", err)
		return err
	}

	// Update alert rules for each folder
	return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		for _, folderUID := range folderUIDs {
			fullpath, ok := folderPaths[folderUID]
			if !ok {
				logger.Warn("Folder not found, skipping fullpath update", "folder_uid", folderUID)
				continue
			}

			// Update all rules in this folder
			_, err := sess.Table(alertRule{}).
				Where("org_id = ? AND namespace_uid = ?", orgID, folderUID).
				MustCols("folder_fullpath").
				Update(map[string]any{"folder_fullpath": fullpath})
			if err != nil {
				logger.Error("Failed to update folder_fullpath for folder", "error", err, "folder_uid", folderUID)
				return err
			}
		}
		return nil
	})
}

// GetAlertRuleByUID is a handler for retrieving an alert rule from that database by its UID and organisation ID.
// It returns ngmodels.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (result *ngmodels.AlertRule, err error) {
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		alertRule := alertRule{OrgID: query.OrgID, UID: query.UID}
		has, err := sess.Get(&alertRule)
		if err != nil {
			return err
		}
		if !has {
			return ngmodels.ErrAlertRuleNotFound
		}
		r, err := alertRuleToModelsAlertRule(alertRule, st.Logger)
		if err != nil {
			return fmt.Errorf("failed to convert alert rule: %w", err)
		}
		result = &r
		return nil
	})
	return result, err
}

func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRuleVersion, error) {
	alertRules := make([]*ngmodels.AlertRuleVersion, 0)
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_guid = ?", orgID, guid).Asc("id").Rows(new(alertRuleVersion))
		if err != nil {
			return err
		}
		// Deserialize each rule separately in case any of them contain invalid JSON.
		var previousVersion *alertRuleVersion
		for rows.Next() {
			rule := new(alertRuleVersion)
			err = rows.Scan(rule)
			if err != nil {
				st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
				continue
			}
			// skip version that has no diff with previous version
			// this is pretty basic comparison, it may have false negatives
			if previousVersion != nil && previousVersion.EqualSpec(*rule) {
				continue
			}
			converted, err := alertRuleVersionToModelsAlertRuleVersion(*rule, st.Logger)
			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
				continue
			}
			previousVersion = rule
			alertRules = append(alertRules, &converted)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRuleVersion) int {
		if a.ID > b.ID {
			return -1
		}
		if a.ID < b.ID {
			return 1
		}
		return 0
	})
	return alertRules, nil
}

// GetAlertRuleVersionFolders retrieves a list of unique folder UIDs that the given rule guid has belonged to.
// Returned slice is ordered with more recent folders first.
func (st DBstore) GetAlertRuleVersionFolders(ctx context.Context, orgID int64, guid string) ([]string, error) {
	folders := make([]string, 0)
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		if err := sess.Table(new(alertRuleVersion)).
			Select("rule_namespace_uid").
			Where("rule_org_id = ? AND rule_guid = ?", orgID, guid).
			GroupBy("rule_namespace_uid").
			OrderBy("MAX(version) DESC").
			Find(&folders); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return folders, nil
}

// ListDeletedRules retrieves a list of deleted alert rules for the specified organization ID from the database.
// It ensures that only the latest version of each rule is included and filters out invalid or duplicated versions.
// Returns a slice of *models.AlertRule  or an error if the operation fails.
func (st DBstore) ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodels.AlertRule, error) {
	alertRules := make([]*ngmodels.AlertRule, 0)
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		// take only the latest versions of each rule by GUID
		rows, err := sess.Table(alertRuleVersion{}).Where("rule_org_id = ? AND rule_uid = ''", orgID).Desc("created", "id").Rows(alertRuleVersion{})
		if err != nil {
			return err
		}
		// Deserialize each rule separately in case any of them contain invalid JSON.
		for rows.Next() {
			rule := new(alertRuleVersion)
			err = rows.Scan(rule)
			if err != nil {
				st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
				continue
			}
			// Note: Message is not returned as a message cannot be set when deleting rules.
			converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
				continue
			}
			alertRules = append(alertRules, &converted)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return alertRules, nil
}

// GetRuleByID retrieves models.AlertRule by ID.
// It returns models.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
func (st DBstore) GetRuleByID(ctx context.Context, query ngmodels.GetAlertRuleByIDQuery) (result *ngmodels.AlertRule, err error) {
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		alertRule := alertRule{OrgID: query.OrgID, ID: query.ID}
		has, err := sess.Get(&alertRule)
		if err != nil {
			return err
		}
		if !has {
			return ngmodels.ErrAlertRuleNotFound
		}
		r, err := alertRuleToModelsAlertRule(alertRule, st.Logger)
		if err != nil {
			return fmt.Errorf("failed to convert alert rule: %w", err)
		}
		result = &r
		return nil
	})
	return result, err
}

// GetAlertRulesGroupByRuleUID is a handler for retrieving a group of alert rules from that database by UID and organisation ID of one of rules that belong to that group.
func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) (result []*ngmodels.AlertRule, err error) {
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		var rules []alertRule
		err := sess.Table("alert_rule").Alias("a").Join(
			"INNER",
			"alert_rule AS b", "a.org_id = b.org_id AND a.namespace_uid = b.namespace_uid AND a.rule_group = b.rule_group AND b.uid = ?", query.UID,
		).Where("a.org_id = ?", query.OrgID).Select("a.*").Find(&rules)
		if err != nil {
			return err
		}
		// MySQL by default compares strings without case-sensitivity, make sure we keep the case-sensitive comparison.
		var groupName, namespaceUID string
		// find the rule, which group we fetch
		for _, rule := range rules {
			if rule.UID == query.UID {
				groupName = rule.RuleGroup
				namespaceUID = rule.NamespaceUID
				break
			}
		}
		result = make([]*ngmodels.AlertRule, 0, len(rules))
		// MySQL (and potentially other databases) can use case-insensitive comparison.
		// This code makes sure we return groups that only exactly match the filter.
		for _, rule := range rules {
			if rule.RuleGroup != groupName || rule.NamespaceUID != namespaceUID {
				continue
			}
			convert, err := alertRuleToModelsAlertRule(rule, st.Logger)
			if err != nil {
				return fmt.Errorf("failed to convert alert rule %q: %w", rule.UID, err)
			}
			result = append(result, &convert)
		}
		return nil
	})
	return result, err
}

// orgNamespaces represents a collection of unique namespace UIDs for a specific organization.
type orgNamespaces struct {
	OrgID         int64
	NamespaceUIDs []string
}

// collectNamespaceUIDsByOrg extracts unique namespace UIDs grouped by org ID from a slice of alert rules.
// Returns a slice of orgNamespaces, one per organization.
func collectNamespaceUIDsByOrg(rules []*ngmodels.AlertRule) []orgNamespaces {
	// Use map to collect unique namespace UIDs per org
	namespaceUIDsByOrg := make(map[int64]map[string]struct{})
	for _, rule := range rules {
		if rule.NamespaceUID != "" {
			if namespaceUIDsByOrg[rule.OrgID] == nil {
				namespaceUIDsByOrg[rule.OrgID] = make(map[string]struct{})
			}
			namespaceUIDsByOrg[rule.OrgID][rule.NamespaceUID] = struct{}{}
		}
	}

	// Convert to slice of orgNamespaces
	result := make([]orgNamespaces, 0, len(namespaceUIDsByOrg))
	for orgID, namespaceUIDs := range namespaceUIDsByOrg {
		uids := make([]string, 0, len(namespaceUIDs))
		for uid := range namespaceUIDs {
			uids = append(uids, uid)
		}
		result = append(result, orgNamespaces{
			OrgID:         orgID,
			NamespaceUIDs: uids,
		})
	}
	return result
}

// fetchFolderFullpathsByOrg fetches folder fullpaths for all namespace UIDs grouped by org ID.
// Returns a map where keys are org IDs and values are maps of namespace UID to folder fullpath.
// If fetching fails for an org, that org will not be in the result map.
func (st DBstore) fetchFolderFullpathsByOrg(ctx context.Context, orgNamespaces []orgNamespaces) map[int64]map[string]string {
	folderFullpathsByOrg := make(map[int64]map[string]string)
	for _, org := range orgNamespaces {
		paths, err := st.getFolderFullpaths(ctx, org.OrgID, org.NamespaceUIDs)
		if err != nil {
			st.Logger.Warn("Failed to fetch folder fullpaths", "org_id", org.OrgID, "error", err)
			continue
		}
		if paths != nil {
			folderFullpathsByOrg[org.OrgID] = paths
		}
	}
	return folderFullpathsByOrg
}

// InsertAlertRules is a handler for creating/updating alert rules.
// Returns the UID and ID of rules that were created in the same order as the input rules.
func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.InsertRule) ([]ngmodels.AlertRuleKeyWithId, error) {
	ids := make([]ngmodels.AlertRuleKeyWithId, 0, len(rules))
	keys := make([]ngmodels.AlertRuleKey, 0, len(rules))

	alertRules := make([]*ngmodels.AlertRule, len(rules))
	for i := range rules {
		alertRules[i] = &rules[i].AlertRule
	}

	orgNamespaces := collectNamespaceUIDsByOrg(alertRules)
	folderFullpathsByOrg := st.fetchFolderFullpathsByOrg(ctx, orgNamespaces)

	return ids, st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
		newRules := make([]alertRule, 0, len(rules))
		ruleVersions := make([]alertRuleVersion, 0, len(rules))
		for i := range rules {
			r := rules[i]
			if r.UID == "" {
				uid, err := GenerateNewAlertRuleUID(sess, r.OrgID, r.Title)
				if err != nil {
					return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.Title, err)
				}
				r.UID = uid
			}
			r.Version = 1
			if err := st.validateAlertRule(r.AlertRule); err != nil {
				return err
			}
			if err := (&r).PreSave(TimeNow, user); err != nil {
				return err
			}

			// Populate folder fullpath from pre-fetched map
			if r.NamespaceUID != "" {
				if orgPaths, ok := folderFullpathsByOrg[r.OrgID]; ok {
					r.FolderFullpath = orgPaths[r.NamespaceUID]
				}
			}

			converted, err := alertRuleFromModelsAlertRule(r.AlertRule)
			if err != nil {
				return fmt.Errorf("failed to convert alert rule %q to storage model: %w", r.Title, err)
			}

			// assign unique identifier that will identify resource across space and time. The probability of collision is so low that we do not need to check for uniqueness.
			// The unique keys will ensure uniqueness in rule and versions tables
			converted.GUID = uuid.NewString()

			newRules = append(newRules, converted)
			v := alertRuleToAlertRuleVersion(converted)
			v.Message = r.Message
			ruleVersions = append(ruleVersions, v)
		}
		if len(newRules) > 0 {
			// we have to insert the rules one by one as otherwise we are
			// not able to fetch the inserted id as it's not supported by xorm
			for i := range newRules {
				if _, err := sess.Insert(&newRules[i]); err != nil {
					if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
						return ngmodels.ErrAlertRuleConflict(newRules[i].UID, newRules[i].OrgID, err)
					}
					return fmt.Errorf("failed to create new rules: %w", err)
				}
				r := newRules[i]
				key := ngmodels.AlertRuleKey{OrgID: r.OrgID, UID: r.UID}

				ids = append(ids, ngmodels.AlertRuleKeyWithId{AlertRuleKey: key, ID: r.ID})
				keys = append(keys, key)
			}
		}

		if len(ruleVersions) > 0 {
			if _, err := sess.Insert(&ruleVersions); err != nil {
				return fmt.Errorf("failed to create new rule versions: %w", err)
			}
		}

		if len(keys) > 0 {
			_ = st.Bus.Publish(ctx, &RuleChangeEvent{
				RuleKeys: keys,
			})
		}
		return nil
	})
}

// UpdateAlertRules is a handler for updating alert rules.
func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error {
	alertRules := make([]*ngmodels.AlertRule, len(rules))
	for i := range rules {
		alertRules[i] = &rules[i].New
	}

	orgNamespaces := collectNamespaceUIDsByOrg(alertRules)
	folderFullpathsByOrg := st.fetchFolderFullpathsByOrg(ctx, orgNamespaces)

	return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
		err := st.preventIntermediateUniqueConstraintViolations(sess, rules)
		if err != nil {
			return fmt.Errorf("failed when preventing intermediate unique constraint violation: %w", err)
		}

		ruleVersions := make([]alertRuleVersion, 0, len(rules))
		keys := make([]ngmodels.AlertRuleKey, 0, len(rules))
		for i := range rules {
			// We do indexed access way to avoid "G601: Implicit memory aliasing in for loop."
			// Doing this will be unnecessary with go 1.22 https://stackoverflow.com/a/68247837/767660
			r := rules[i]
			r.New.ID = r.Existing.ID
			r.New.Version = r.Existing.Version // xorm will take care of increasing it (see https://xorm.io/docs/chapter-06/1.lock/)
			r.New.GUID = r.Existing.GUID
			if err := st.validateAlertRule(r.New); err != nil {
				return err
			}
			if err := (&r.New).PreSave(TimeNow, user); err != nil {
				return err
			}

			// Populate folder fullpath from pre-fetched map
			if r.New.NamespaceUID != "" {
				if orgPaths, ok := folderFullpathsByOrg[r.New.OrgID]; ok {
					r.New.FolderFullpath = orgPaths[r.New.NamespaceUID]
				}
			}

			converted, err := alertRuleFromModelsAlertRule(r.New)
			if err != nil {
				return fmt.Errorf("failed to convert alert rule %s to storage model: %w", r.New.UID, err)
			}
			// no way to update multiple rules at once
			if updated, err := sess.ID(r.Existing.ID).AllCols().Omit("rule_guid").Update(converted); err != nil || updated == 0 {
				if err != nil {
					if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
						return ngmodels.ErrAlertRuleConflict(r.New.UID, r.New.OrgID, err)
					}
					return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
				}
				return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.New.UID, r.New.Version)
			}
			v := alertRuleToAlertRuleVersion(converted)
			v.Version++
			v.ParentVersion = r.Existing.Version
			v.Message = r.Message

			// check if there is diff between existing and new, and if no, skip saving version.
			existingConverted, err := alertRuleFromModelsAlertRule(*r.Existing)
			if err != nil || !alertRuleToAlertRuleVersion(existingConverted).EqualSpec(v) {
				ruleVersions = append(ruleVersions, v)
			}

			keys = append(keys, ngmodels.AlertRuleKey{OrgID: r.New.OrgID, UID: r.New.UID})
		}
		if len(ruleVersions) > 0 {
			if _, err := sess.Insert(&ruleVersions); err != nil {
				return fmt.Errorf("failed to create new rule versions: %w", err)
			}
			st.deleteOldAlertRuleVersions(ctx, sess, ruleVersions)
		}
		if len(keys) > 0 {
			_ = st.Bus.Publish(ctx, &RuleChangeEvent{
				RuleKeys: keys,
			})
		}
		return nil
	})
}

func (st DBstore) deleteOldAlertRuleVersions(ctx context.Context, sess *db.Session, versions []alertRuleVersion) {
	if st.Cfg.RuleVersionRecordLimit < 1 {
		return
	}
	logger := st.Logger.FromContext(ctx)
	for _, rv := range versions {
		deleteTo := rv.Version - int64(st.Cfg.RuleVersionRecordLimit)
		// if the last version is less that retention, do nothing
		if deleteTo <= 1 {
			continue
		}
		logger := logger.New("org_id", rv.RuleOrgID, "rule_uid", rv.RuleUID, "version", rv.Version, "limit", st.Cfg.RulesPerRuleGroupLimit)
		res, err := sess.Exec(`DELETE FROM alert_rule_version WHERE rule_guid = ? AND version <= ?`, rv.RuleGUID, deleteTo)
		if err != nil {
			logger.Error("Failed to delete old alert rule versions", "error", err)
			return
		}
		rows, err := res.RowsAffected()
		if err != nil {
			rows = -1
		}
		if rows != 0 {
			logger.Info("Deleted old alert_rule_version(s)", "deleted", rows)
		}
	}
}

// preventIntermediateUniqueConstraintViolations prevents unique constraint violations caused by an intermediate update.
// The uniqueness constraint for titles within an org+folder is enforced on every update within a transaction
// instead of on commit (deferred constraint). This means that there could be a set of updates that will throw
// a unique constraint violation in an intermediate step even though the final state is valid.
// For example, a chain of updates RuleA -> RuleB -> RuleC could fail if not executed in the correct order, or
// a swap of titles RuleA <-> RuleB cannot be executed in any order without violating the constraint.
func (st DBstore) preventIntermediateUniqueConstraintViolations(sess *db.Session, updates []ngmodels.UpdateRule) error {
	// The exact solution to this is complex and requires determining directed paths and cycles in the update graph,
	// adding in temporary updates to break cycles, and then executing the updates in reverse topological order.
	// This is not implemented here. Instead, we choose a simpler solution that works in all cases but might perform
	// more updates than necessary. This simpler solution makes a determination of whether an intermediate collision
	// could occur and if so, adds a temporary title on all updated rules to break any cycles and remove the need for
	// specific ordering.

	titleUpdates := make([]ngmodels.UpdateRule, 0)
	for _, update := range updates {
		if update.Existing.Title != update.New.Title {
			titleUpdates = append(titleUpdates, update)
		}
	}

	// If there is no overlap then an intermediate unique constraint violation is not possible. If there is an overlap,
	// then there is the possibility of intermediate unique constraint violation.
	if !newTitlesOverlapExisting(titleUpdates) {
		return nil
	}
	st.Logger.Debug("Detected possible intermediate unique constraint violation, creating temporary title updates", "updates", len(titleUpdates))

	for _, update := range titleUpdates {
		r := update.Existing
		u := uuid.New().String()

		// Some defensive programming in case the temporary title is somehow persisted it will still be recognizable.
		uniqueTempTitle := r.Title + u
		if len(uniqueTempTitle) > AlertRuleMaxTitleLength {
			uniqueTempTitle = r.Title[:AlertRuleMaxTitleLength-len(u)] + uuid.New().String()
		}

		if updated, err := sess.ID(r.ID).Cols("title").Update(&alertRule{Title: uniqueTempTitle, Version: r.Version}); err != nil || updated == 0 {
			if err != nil {
				return fmt.Errorf("failed to set temporary rule title [%s] %s: %w", r.UID, r.Title, err)
			}
			return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.UID, r.Version)
		}
		// Otherwise optimistic locking will conflict on the 2nd update.
		r.Version++
		// For consistency.
		r.Title = uniqueTempTitle
	}

	return nil
}

// newTitlesOverlapExisting returns true if any new titles overlap with existing titles.
// It does so in a case-insensitive manner as some supported databases perform case-insensitive comparisons.
func newTitlesOverlapExisting(rules []ngmodels.UpdateRule) bool {
	existingTitles := make(map[string]struct{}, len(rules))
	for _, r := range rules {
		existingTitles[strings.ToLower(r.Existing.Title)] = struct{}{}
	}

	// Check if there is any overlap between lower case existing and new titles.
	for _, r := range rules {
		if _, ok := existingTitles[strings.ToLower(r.New.Title)]; ok {
			return true
		}
	}

	return false
}

// CountInFolder is a handler for retrieving the number of alert rules of
// specific organisation associated with a given namespace (parent folder).
func (st DBstore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, _ identity.Requester) (int64, error) {
	if len(folderUIDs) == 0 {
		return 0, nil
	}
	var count int64
	var err error
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		args := make([]any, 0, len(folderUIDs))
		for _, folderUID := range folderUIDs {
			args = append(args, folderUID)
		}
		q := sess.Table("alert_rule").Where("org_id = ?", orgID).Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Repeat("?,", len(folderUIDs)-1)+"?"), args...)
		count, err = q.Count()
		return err
	})
	return count, err
}

func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.ListAlertRulesExtendedQuery) (result ngmodels.RulesGroup, nextToken string, err error) {
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		q, groupsSet, err := st.buildListAlertRulesQuery(sess, query)
		if err != nil {
			return err
		}

		var cursor ngmodels.GroupCursor
		if query.ContinueToken != "" {
			// only set the cursor if it's valid, otherwise we'll start from the beginning
			if cur, err := ngmodels.DecodeGroupCursor(query.ContinueToken); err == nil {
				cursor = cur
			}
		}

		// Build group cursor condition
		if cursor.NamespaceUID != "" {
			q = buildGroupCursorCondition(q, cursor)
		}

		// No arbitrary fetch limit - let the loop control pagination
		alertRules := make([]*ngmodels.AlertRule, 0)
		rule := new(alertRule)
		rows, err := q.Rows(rule)
		if err != nil {
			return err
		}
		defer func() {
			_ = rows.Close()
		}()

		opts := AlertRuleConvertOptions{}
		if query.Compact {
			opts.ExcludeAlertQueries = true
			opts.ExcludeContactPointRouting = true
			opts.ExcludeMetadata = true

			if query.ReceiverName != "" || query.TimeIntervalName != "" {
				// Need ContactPointRouting for these filters
				opts.ExcludeContactPointRouting = false
			}

			if query.HasPrometheusRuleDefinition != nil {
				// Need Metadata for this filter
				opts.ExcludeMetadata = false
			}
		}

		// Process rules and implement per-group pagination
		var groupsFetched int64
		var rulesFetched int64
		for rows.Next() {
			rule := new(alertRule)
			err = rows.Scan(rule)
			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRulesByGroup", "error", err)
				continue
			}

			converted, err := convertAlertRuleToModel(*rule, st.Logger, opts)

			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRulesByGroup", "error", err)
				continue
			}

			// Check if we've moved to a new group
			key := ngmodels.GroupCursor{
				NamespaceUID: converted.NamespaceUID,
				RuleGroup:    converted.RuleGroup,
			}
			if query.SortByFullpath {
				key.FolderFullpath = converted.FolderFullpath
			}
			if key != cursor {
				// Check if we've reached the group limit
				if query.Limit > 0 && groupsFetched == query.Limit {
					// Generate next token for the next group
					nextToken = ngmodels.EncodeGroupCursor(cursor)
					break
				}
				// Check if we've reached the rule limit
				if query.RuleLimit > 0 && rulesFetched >= query.RuleLimit {
					nextToken = ngmodels.EncodeGroupCursor(cursor)
					break
				}

				// Reset for new group
				cursor = key
				groupsFetched++
			}

			// Apply post-query filters
			if !shouldIncludeRule(&converted, query, groupsSet) {
				continue
			}

			alertRules = append(alertRules, &converted)
			rulesFetched++
		}

		result = alertRules
		return nil
	})
	return result, nextToken, err
}

func buildGroupCursorCondition(sess *xorm.Session, c ngmodels.GroupCursor) *xorm.Session {
	// We need to handle this here otherwise we end up checking rule_group > "no_group_for_rule..."
	// and skipping everything
	ruleGroup := c.RuleGroup
	if ngmodels.IsNoGroupRuleGroup(ruleGroup) {
		ruleGroup = ""
	}

	if c.FolderFullpath != "" {
		return sess.And(
			"((folder_fullpath > ?) OR (folder_fullpath = ? AND namespace_uid > ?) OR (folder_fullpath = ? AND namespace_uid = ? AND rule_group > ?))",
			c.FolderFullpath,
			c.FolderFullpath, c.NamespaceUID,
			c.FolderFullpath, c.NamespaceUID, ruleGroup,
		)
	}
	// fallback to previous cursor condition if folder fullpath is not available, this means that pagination will be less efficient as it cannot take advantage of folder fullpath ordering, but at least it will work and not return duplicate or missing groups.
	return sess.And(
		"((namespace_uid > ?) OR (namespace_uid = ? AND rule_group > ?))",
		c.NamespaceUID, c.NamespaceUID, ruleGroup,
	)
}

func shouldIncludeRule(rule *ngmodels.AlertRule, query *ngmodels.ListAlertRulesExtendedQuery, groupsMap map[string]struct{}) bool {
	settings := rule.ContactPointRouting()
	if query.ReceiverName != "" {
		if settings == nil {
			return false
		}
		if settings.Receiver != query.ReceiverName {
			return false
		}
	}

	if query.TimeIntervalName != "" {
		if settings == nil {
			return false
		}
		if !slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) &&
			!slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName) {
			return false
		}
	}

	if query.HasPrometheusRuleDefinition != nil {
		if *query.HasPrometheusRuleDefinition != rule.HasPrometheusRuleDefinition() {
			return false
		}
	}

	if groupsMap != nil {
		if _, ok := groupsMap[rule.RuleGroup]; !ok {
			return false
		}
	}

	return true
}

func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (result ngmodels.RulesGroup, err error) {
	result, nextToken, err := st.ListAlertRulesPaginated(ctx, &ngmodels.ListAlertRulesExtendedQuery{
		ListAlertRulesQuery: *query,
		ContinueToken:       "",
		Limit:               0,
		RuleType:            ngmodels.RuleTypeFilterAll,
	})
	// This should never happen, as Limit is 0, which means no pagination.
	if nextToken != "" {
		err = fmt.Errorf("unexpected next token %q, expected empty string", nextToken)
		st.Logger.Error("ListAlertRules returned a next token, but it should not have, this is a bug!", "next_token", nextToken, "query", query)
	}
	return result, err
}

// ListAlertRulesPaginated is a handler for retrieving alert rules of specific organization paginated.
func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.ListAlertRulesExtendedQuery) (result ngmodels.RulesGroup, nextToken string, err error) {
	err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		q, groupsSet, err := st.buildListAlertRulesQuery(sess, query)
		if err != nil {
			return err
		}

		if query.ContinueToken != "" {
			cursor, err := decodeCursor(query.ContinueToken)
			if err != nil {
				return fmt.Errorf("invalid continue token: %w", err)
			}

			// Build cursor condition that matches the ORDER BY clause
			q = buildCursorCondition(q, cursor)
		}

		if query.Limit > 0 {
			// Ensure we clamp to the max int available on the platform
			lim := min(query.Limit, math.MaxInt)
			// Fetch one extra rule to determine if there are more results
			q = q.Limit(int(lim) + 1)
		}

		alertRules := make([]*ngmodels.AlertRule, 0)
		rule := new(alertRule)
		rows, err := q.Rows(rule)
		if err != nil {
			return err
		}
		defer func() {
			_ = rows.Close()
		}()

		// Deserialize each rule separately in case any of them contain invalid JSON.
		for rows.Next() {
			converted, ok := st.handleRuleRow(rows, query, groupsSet)
			if ok {
				alertRules = append(alertRules, converted)
			}
		}

		genToken := query.Limit > 0 && len(alertRules) > int(query.Limit)
		if genToken {
			// Remove the extra item we fetched
			alertRules = alertRules[:query.Limit]

			// Generate next continue token from the last item
			lastRule := alertRules[len(alertRules)-1]
			cursor := continueCursor{
				NamespaceUID: lastRule.NamespaceUID,
				RuleGroup:    lastRule.RuleGroup,
				RuleGroupIdx: int64(lastRule.RuleGroupIndex),
				ID:           lastRule.ID,
			}

			nextToken = encodeCursor(cursor)
		}

		result = alertRules
		return nil
	})
	return result, nextToken, err
}

func matchersMatchLabels(matchers labels.Matchers, lbls map[string]string) bool {
	for _, m := range matchers {
		if !m.Matches(lbls[m.Name]) {
			return false
		}
	}
	return true
}

// This will generate the appropriate uid filter for any groups with the no group prefix
func buildRuleGroupFilter(q *xorm.Session, ruleGroups []string) (*xorm.Session, map[string]struct{}, error) {
	var noGroupRuleGroupRuleUIDs []string
	var realGroups []string
	if len(ruleGroups) > 0 {
		groupsSet := make(map[string]struct{})
		for _, group := range ruleGroups {
			if ngmodels.IsNoGroupRuleGroup(group) {
				noGroupRuleGroup, err := ngmodels.ParseNoRuleGroup(group)
				if err != nil {
					return nil, groupsSet, fmt.Errorf("failed to parse rule group %q: %w", group, err)
				}
				noGroupRuleGroupRuleUIDs = append(noGroupRuleGroupRuleUIDs, noGroupRuleGroup.GetRuleUID())
			} else {
				realGroups = append(realGroups, group)
			}
			groupsSet[group] = struct{}{}
		}
		switch {
		// all real rule groups,
		case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) == 0:
			groupArgs, groupIn := getINSubQueryArgs(realGroups)
			q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...)
		// all no-group rule groups
		case len(realGroups) == 0 && len(noGroupRuleGroupRuleUIDs) > 0:
			ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
			q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...)
		// mixed case, we need to perform the or
		case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) > 0:
			groupArgs, groupIn := getINSubQueryArgs(realGroups)
			ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
			q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...).Or(
				fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...,
			)
		}

		return q, groupsSet, nil
	}

	return q, nil, nil
}

// nolint:gocyclo
func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) {
	q = sess.Table("alert_rule")
	if query.OrgID >= 0 {
		q = q.Where("org_id = ?", query.OrgID)
	}

	if query.DashboardUID != "" {
		q = q.Where("dashboard_uid = ?", query.DashboardUID)
		if query.PanelID != 0 {
			q = q.Where("panel_id = ?", query.PanelID)
		}
	}

	if len(query.NamespaceUIDs) > 0 {
		args, in := getINSubQueryArgs(query.NamespaceUIDs)
		q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
	}

	if len(query.RuleUIDs) > 0 {
		args, in := getINSubQueryArgs(query.RuleUIDs)
		q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
	}

	q, groupsSet, err = buildRuleGroupFilter(q, query.RuleGroups)
	if err != nil {
		return nil, groupsSet, err
	}

	if query.ReceiverName != "" {
		q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
		if err != nil {
			return nil, groupsSet, err
		}
	}

	if query.TimeIntervalName != "" {
		q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
		if err != nil {
			return nil, groupsSet, err
		}
	}

	if len(query.DataSourceUIDs) > 0 {
		orConditions := make([]string, 0, len(query.DataSourceUIDs))
		orParams := make([]interface{}, 0, len(query.DataSourceUIDs))
		for _, dsUID := range query.DataSourceUIDs {
			// The 'data' column holds the alert definition as JSON. The data source's UID is in the 'datasourceUid' field.
			// Instead of trying to parse that JSON, we can do a simple text search.
			// All alert rules go through PreSave(), which normalizes the JSON data using json.Marshal().
			pattern := fmt.Sprintf(`"datasourceUid":"%s"`, dsUID)
			sql, param := st.SQLStore.GetDialect().LikeOperator("data", true, pattern, true)
			orConditions = append(orConditions, sql)
			orParams = append(orParams, param)
		}

		q = q.And("("+strings.Join(orConditions, " OR ")+")", orParams...)
	}

	if query.SearchTitle != "" {
		words := strings.Fields(query.SearchTitle)
		if len(words) > 0 {
			// Build sequential pattern: %word1%word2%word3%
			pattern := strings.Join(words, "%")
			sql, param := st.SQLStore.GetDialect().LikeOperator("title", true, pattern, true)
			q = q.And(sql, param)
		}
	}

	if query.SearchRuleGroup != "" {
		normalizedInput := strings.ToLower(query.SearchRuleGroup)
		words := strings.Fields(normalizedInput)

		if len(words) > 0 {
			pattern := "%" + strings.Join(words, "%") + "%"
			// In MySQL rule_group field has case-sensitive collation by default,
			// so we need to use LOWER to perform case-insensitive search.
			q = q.And("LOWER(rule_group) LIKE ?", pattern)
		}
	}

	if query.HasPrometheusRuleDefinition != nil {
		q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
		if err != nil {
			return nil, groupsSet, err
		}
	}

	if len(query.LabelMatchers) > 0 {
		q, err = st.filterByLabelMatchers(query.LabelMatchers, q)
		if err != nil {
			return nil, groupsSet, err
		}
	}

	if query.PluginOriginFilter != ngmodels.PluginOriginFilterNone {
		q, err = st.filterByPluginOrigin(query.PluginOriginFilter, q)
		if err != nil {
			return nil, groupsSet, err
		}
	}

	// FIXME: record is nullable but we don't save it as null when it's nil
	switch query.RuleType {
	case ngmodels.RuleTypeFilterAlerting:
		q = q.Where("record = ''")
	case ngmodels.RuleTypeFilterRecording:
		q = q.Where("record != ''")
	case ngmodels.RuleTypeFilterAll:
		// no additional filter
	default:
		return nil, groupsSet, fmt.Errorf("unknown rule type filter %v", query.RuleType)
	}

	if query.SortByFullpath {
		q = applyListAlertRulesOrderByFullpath(q)
	} else {
		q = applyListAlertRulesOrderByNamespace(q)
	}
	return q, groupsSet, nil
}

func applyListAlertRulesOrderByNamespace(q *xorm.Session) *xorm.Session {
	return q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
}

func applyListAlertRulesOrderByFullpath(q *xorm.Session) *xorm.Session {
	return q.Asc("folder_fullpath", "namespace_uid", "rule_group", "rule_group_idx", "id")
}

func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesExtendedQuery, groupsSet map[string]struct{}) (*ngmodels.AlertRule, bool) {
	rule := new(alertRule)
	err := rows.Scan(rule)
	if err != nil {
		st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err)
		return nil, false
	}
	converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
	if err != nil {
		st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err)
		return nil, false
	}
	settings := converted.ContactPointRouting()
	if query.ReceiverName != "" { // remove false-positive hits from the result
		if settings == nil {
			return nil, false
		}
		if settings.Receiver != query.ReceiverName {
			return nil, false
		}
	}
	if query.TimeIntervalName != "" {
		if settings == nil {
			return nil, false
		}
		if !slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) &&
			!slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName) {
			return nil, false
		}
	}
	if query.HasPrometheusRuleDefinition != nil { // remove false-positive hits from the result
		if *query.HasPrometheusRuleDefinition != converted.HasPrometheusRuleDefinition() {
			return nil, false
		}
	}
	if len(query.LabelMatchers) > 0 { // remove false-positive hits from the result
		if !matchersMatchLabels(query.LabelMatchers, converted.Labels) {
			return nil, false
		}
	}
	// MySQL (and potentially other databases) can use case-insensitive comparison.
	// This code makes sure we return groups that only exactly match the filter.
	if groupsSet != nil {
		if _, ok := groupsSet[converted.RuleGroup]; !ok {
			return nil, false
		}
	}
	return &converted, true
}

type continueCursor struct {
	NamespaceUID string `json:"n"`
	RuleGroup    string `json:"g"`
	RuleGroupIdx int64  `json:"i"`
	ID           int64  `json:"d"`
}

func encodeCursor(c continueCursor) string {
	data, _ := json.Marshal(c)
	return base64.URLEncoding.EncodeToString(data)
}

func decodeCursor(token string) (continueCursor, error) {
	var c continueCursor
	data, err := base64.URLEncoding.DecodeString(token)
	if err != nil {
		return c, fmt.Errorf("failed to decode token: %w", err)
	}

	if err := json.Unmarshal(data, &c); err != nil {
		return c, fmt.Errorf("failed to unmarshal cursor: %w", err)
	}

	return c, nil
}

func buildCursorCondition(sess *xorm.Session, c continueCursor) *xorm.Session {
	// We need to handle this here otherwise we end up checking rule_group > "no_group_for_rule..."
	// and skipping everything
	ruleGroup := c.RuleGroup
	if ngmodels.IsNoGroupRuleGroup(ruleGroup) {
		ruleGroup = ""
	}

	return sess.And(`(
		(namespace_uid > ?)
		OR (namespace_uid = ? AND rule_group > ?)
		OR (namespace_uid = ? AND rule_group = ? AND rule_group_idx > ?)
		OR (namespace_uid = ? AND rule_group = ? AND rule_group_idx = ? AND id > ?)
	)`,
		c.NamespaceUID,
		c.NamespaceUID, ruleGroup,
		c.NamespaceUID, ruleGroup, c.RuleGroupIdx,
		c.NamespaceUID, ruleGroup, c.RuleGroupIdx, c.ID,
	)
}

// Count returns either the number of the alert rules under a specific org (if orgID is not zero)
// or the number of all the alert rules
func (st DBstore) Count(ctx context.Context, orgID int64) (int64, error) {
	type result struct {
		Count int64
	}

	r := result{}
	err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
		rawSQL := "SELECT COUNT(*) as count from alert_rule"
		args := make([]any, 0)
		if orgID != 0 {
			rawSQL += " WHERE org_id=?"
			args = append(args, orgID)
		}
		if _, err := sess.SQL(rawSQL, args...).Get(&r); err != nil {
			return err
		}
		return nil
	})
	return r.Count, err
}

func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) {
	var interval int64 = 0
	return interval, st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		ruleGroups := make([]alertRule, 0)
		err := sess.Find(
			&ruleGroups,
			alertRule{OrgID: orgID, RuleGroup: ruleGroup, NamespaceUID: namespaceUID},
		)
		if len(ruleGroups) == 0 {
			return ngmodels.ErrAlertRuleGroupNotFound.Errorf("")
		}
		interval = ruleGroups[0].IntervalSeconds
		return err
	})
}

func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodels.AlertRuleKeyWithVersion, error) {
	var result []ngmodels.AlertRuleKeyWithVersion
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		alertRulesSql := sess.Table("alert_rule").Select("org_id, uid, version")
		disabledOrgs := make([]int64, 0, len(st.Cfg.DisabledOrgs))

		for orgID := range st.Cfg.DisabledOrgs {
			disabledOrgs = append(disabledOrgs, orgID)
		}

		if len(disabledOrgs) > 0 {
			alertRulesSql = alertRulesSql.NotIn("org_id", disabledOrgs)
		}

		if err := alertRulesSql.Find(&result); err != nil {
			return err
		}

		return nil
	})
	return result, err
}

// GetAlertRulesForScheduling returns a short version of all alert rules except those that belong to an excluded list of organizations
func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error {
	var rules []*ngmodels.AlertRule
	return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		disabledOrgs := make([]int64, 0, len(st.Cfg.DisabledOrgs))
		for orgID := range st.Cfg.DisabledOrgs {
			disabledOrgs = append(disabledOrgs, orgID)
		}

		alertRulesSql := sess.Table("alert_rule")
		if len(disabledOrgs) > 0 {
			alertRulesSql.NotIn("org_id", disabledOrgs)
		}

		alertRulesSql, groupsMap, err := buildRuleGroupFilter(alertRulesSql, query.RuleGroups)
		if err != nil {
			return err
		}

		rule := new(alertRule)
		rows, err := alertRulesSql.Rows(rule)
		if err != nil {
			return fmt.Errorf("failed to fetch alert rules: %w", err)
		}
		defer func() {
			if err := rows.Close(); err != nil {
				st.Logger.Error("Unable to close rows session", "error", err)
			}
		}()
		// Deserialize each rule separately in case any of them contain invalid JSON.
		for rows.Next() {
			rule := new(alertRule)
			err = rows.Scan(rule)
			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "GetAlertRulesForScheduling", "error", err)
				continue
			}
			converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
			if err != nil {
				st.Logger.Error("Invalid rule found in DB store, cannot convert it", "func", "GetAlertRulesForScheduling", "error", err)
				continue
			}
			// MySQL (and potentially other databases) uses case-insensitive comparison.
			// This code makes sure we return groups that only exactly match the filter
			if groupsMap != nil {
				if _, ok := groupsMap[converted.RuleGroup]; !ok { // compare groups using case-sensitive logic.
					continue
				}
			}
			//nolint:staticcheck // not yet migrated to OpenFeature
			if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingQueryOptimization) {
				if optimizations, err := OptimizeAlertQueries(converted.Data); err != nil {
					st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err)
				} else if len(optimizations) > 0 {
					st.Logger.Info("Migrated rule from range to instant query", "rule", rule.UID, "migrated_queries", len(optimizations))
				}
			}
			rules = append(rules, &converted)
		}

		query.ResultRules = rules

		if query.PopulateFolders {
			query.ResultFoldersTitles = map[ngmodels.FolderKey]string{}
			uids := map[int64]map[string]struct{}{}
			for _, r := range rules {
				om, ok := uids[r.OrgID]
				if !ok {
					om = make(map[string]struct{})
					uids[r.OrgID] = om
				}
				om[r.NamespaceUID] = struct{}{}
			}
			for orgID, uids := range uids {
				schedulerUser := accesscontrol.BackgroundUser("grafana_scheduler", orgID, org.RoleAdmin,
					[]accesscontrol.Permission{
						{
							Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll,
						},
					})

				folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{
					OrgID:        orgID,
					UIDs:         slices.Collect(maps.Keys(uids)),
					WithFullpath: true,
					SignedInUser: schedulerUser,
				})
				if err != nil {
					return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err)
				}
				for _, f := range folders {
					query.ResultFoldersTitles[ngmodels.FolderKey{OrgID: f.OrgID, UID: f.UID}] = f.Fullpath
				}
			}
		}
		return nil
	})
}

// DeleteInFolder deletes the rules contained in a given folder along with their associated data.
func (st DBstore) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error {
	for _, folderUID := range folderUIDs {
		evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID))
		canSave, err := st.AccessControl.Evaluate(ctx, user, evaluator)
		if err != nil {
			st.Logger.Error("Failed to evaluate access control", "error", err)
			return err
		}
		if !canSave {
			st.Logger.Error("user is not allowed to delete alert rules in folder", "folder", folderUID, "user")
			return dashboards.ErrFolderAccessDenied
		}

		rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
			OrgID:         orgID,
			NamespaceUIDs: []string{folderUID},
		})
		if err != nil {
			return err
		}

		uids := make([]string, 0, len(rules))
		for _, tgt := range rules {
			if tgt != nil {
				uids = append(uids, tgt.UID)
			}
		}

		if err := st.DeleteAlertRulesByUID(ctx, orgID, ngmodels.NewUserUID(user), false, uids...); err != nil {
			return err
		}
	}
	return nil
}

// Kind returns the name of the alert rule type of entity.
func (st DBstore) Kind() string { return entity.StandardKindAlertRule }

// GenerateNewAlertRuleUID generates a unique UID for a rule.
// This is set as a variable so that the tests can override it.
// The ruleTitle is only used by the mocked functions.
var GenerateNewAlertRuleUID = func(sess *db.Session, orgID int64, ruleTitle string) (string, error) {
	for i := 0; i < 3; i++ {
		uid := util.GenerateShortUID()

		exists, err := sess.Where("org_id=? AND uid=?", orgID, uid).Get(&alertRule{})
		if err != nil {
			return "", err
		}

		if !exists {
			return uid, nil
		}
	}

	return "", ngmodels.ErrAlertRuleFailedGenerateUniqueUID
}

// validateAlertRule validates the alert rule including db-level restrictions on field lengths.
func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
	if err := alertRule.ValidateAlertRule(st.Cfg); err != nil {
		return err
	}

	// enforce max name length.
	if len(alertRule.Title) > AlertRuleMaxTitleLength {
		return fmt.Errorf("%w: name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxTitleLength)
	}

	// enforce max rule group name length.
	if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength && !ngmodels.IsNoGroupRuleGroup(alertRule.RuleGroup) {
		return fmt.Errorf("%w: rule group name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxRuleGroupNameLength)
	}

	return nil
}

// ListContactPointRoutings fetches all notification settings for given organization
func (st DBstore) ListContactPointRoutings(ctx context.Context, q ngmodels.ListContactPointRoutingsQuery) (map[ngmodels.AlertRuleKey]ngmodels.ContactPointRouting, error) {
	var rules []alertRule
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		query := sess.Table(alertRule{}).Select("uid, notification_settings").Where("org_id = ?", q.OrgID)
		hasFilter := false
		if q.ReceiverName != "" {
			var err error
			query, err = st.filterByContentInNotificationSettings(q.ReceiverName, query)
			if err != nil {
				return err
			}
			hasFilter = true
		}
		if q.TimeIntervalName != "" {
			var err error
			query, err = st.filterByContentInNotificationSettings(q.TimeIntervalName, query)
			if err != nil {
				return err
			}
			hasFilter = true
		}
		if !hasFilter {
			query = query.And("notification_settings IS NOT NULL AND notification_settings <> 'null' AND notification_settings <> ''")
		}
		return query.Find(&rules)
	})
	if err != nil {
		return nil, err
	}
	result := make(map[ngmodels.AlertRuleKey]ngmodels.ContactPointRouting, len(rules))
	for _, rule := range rules {
		if rule.NotificationSettings == "" {
			continue
		}
		converted, err := parseNotificationSettings(rule.NotificationSettings)
		if err != nil {
			return nil, fmt.Errorf("failed to convert notification settings %s to models: %w", rule.UID, err)
		}
		if converted == nil {
			continue
		}

		if q.ReceiverName != "" && q.ReceiverName != converted.Receiver {
			continue
		}
		if q.TimeIntervalName != "" && !slices.Contains(converted.MuteTimeIntervals, q.TimeIntervalName) && !slices.Contains(converted.ActiveTimeIntervals, q.TimeIntervalName) {
			continue
		}
		result[ngmodels.AlertRuleKey{
			OrgID: q.OrgID,
			UID:   rule.UID,
		}] = *converted
	}
	return result, nil
}

func (st DBstore) filterByContentInNotificationSettings(value string, sess *xorm.Session) (*xorm.Session, error) {
	if value == "" {
		return sess, nil
	}
	// marshall string according to JSON rules so we follow escaping rules.
	b, err := json.Marshal(value)
	if err != nil {
		return nil, fmt.Errorf("failed to marshall string for notification settings content filter: %w", err)
	}
	var search = string(b)
	if st.SQLStore.GetDialect().DriverName() != migrator.SQLite {
		// this escapes escaped double quote (\") to \\\"
		search = strings.ReplaceAll(strings.ReplaceAll(search, `\`, `\\`), `"`, `\"`)
	}
	sql, param := st.SQLStore.GetDialect().LikeOperator("notification_settings", true, search, true)
	return sess.And(sql, param), nil
}

func (st DBstore) filterWithPrometheusRuleDefinition(value bool, sess *xorm.Session) (*xorm.Session, error) {
	if value {
		// Filter for rules that have both prometheus_style_rule and original_rule_definition in metadata
		return sess.And(
			"metadata LIKE ? AND metadata LIKE ?",
			"%prometheus_style_rule%",
			"%original_rule_definition%",
		), nil
	}
	// Filter for rules that don't have prometheus_style_rule and original_rule_definition in metadata
	return sess.And(
		"metadata NOT LIKE ? AND metadata NOT LIKE ?",
		"%prometheus_style_rule%",
		"%original_rule_definition%",
	), nil
}

// filterByLabelMatchers adds filtering for equality and inequality label matchers.
// Returns error if regex matchers are passed.
func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Session) (*xorm.Session, error) {
	for _, m := range matchers {
		if m.Type != labels.MatchEqual && m.Type != labels.MatchNotEqual {
			return nil, fmt.Errorf("matcher %q %s %q is not supported", m.Name, m.Type, m.Value)
		}

		sql, args, err := buildLabelMatcherCondition(st.SQLStore.GetDialect(), "labels", m)
		if err != nil {
			return nil, err
		}
		sess = sess.And(sql, args...)
	}
	return sess, nil
}

// filterByPluginOrigin adds filtering for plugin-originated rules based on the __grafana_origin label.
func (st DBstore) filterByPluginOrigin(filter ngmodels.PluginOriginFilter, sess *xorm.Session) (*xorm.Session, error) {
	if filter == ngmodels.PluginOriginFilterNone {
		return sess, nil
	}

	var sql string
	var args []any
	var err error

	switch filter {
	case ngmodels.PluginOriginFilterHide:
		sql, args, err = buildLabelKeyMissingCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel)
	case ngmodels.PluginOriginFilterOnly:
		sql, args, err = buildLabelKeyExistsCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel)
	default:
		return nil, fmt.Errorf("unknown plugin origin filter %q", filter)
	}

	if err != nil {
		return nil, err
	}

	return sess.And(sql, args...), nil
}

func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) {
	// fetch entire rules because Update method requires it because it copies rules to version table
	rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
		OrgID:        orgID,
		ReceiverName: oldReceiver,
	})
	if err != nil {
		return nil, nil, err
	}
	if len(rules) == 0 {
		return nil, nil, nil
	}

	provenances, err := st.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType())
	if err != nil {
		return nil, nil, err
	}

	var invalidProvenance []ngmodels.AlertRuleKey
	result := make([]ngmodels.AlertRuleKey, 0, len(rules))
	updates := make([]ngmodels.UpdateRule, 0, len(rules))
	for _, rule := range rules {
		provenance, ok := provenances[rule.UID]
		if !ok {
			provenance = ngmodels.ProvenanceNone
		}
		if !validateProvenance(provenance) {
			invalidProvenance = append(invalidProvenance, rule.GetKey())
		}
		if len(invalidProvenance) > 0 { // do not do any fixes if there is at least one rule with not allowed provenance
			continue
		}

		result = append(result, rule.GetKey())

		if dryRun {
			continue
		}

		r := rule.Copy()
		settings := r.ContactPointRouting()
		if settings == nil {
			continue
		}

		if settings.Receiver == oldReceiver {
			settings.Receiver = newReceiver
		}

		updates = append(updates, ngmodels.UpdateRule{
			Existing: rule,
			New:      *r,
		})
	}
	if len(invalidProvenance) > 0 {
		return nil, invalidProvenance, nil
	}
	if dryRun {
		return result, nil, nil
	}
	// Provide empty user identifier to ensure it's clear that the rule update was made by the system
	// and not by the user who changed the receiver's title.
	return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
}

// RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name.
// Before renaming, it checks that all rules that need to be updated have allowed provenance status, and skips updating
// if at least one rule does not have allowed provenance.
// It returns a tuple:
// - a collection of models.AlertRuleKey of rules that were updated,
// - a collection of rules that have invalid provenance status,
// - database error
func (st DBstore) RenameTimeIntervalInNotificationSettings(
	ctx context.Context,
	orgID int64,
	oldTimeInterval, newTimeInterval string,
	validateProvenance func(ngmodels.Provenance) bool,
	dryRun bool,
) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) {
	// fetch entire rules because Update method requires it because it copies rules to version table
	rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
		OrgID:            orgID,
		TimeIntervalName: oldTimeInterval,
	})
	if err != nil {
		return nil, nil, err
	}
	if len(rules) == 0 {
		return nil, nil, nil
	}

	provenances, err := st.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType())
	if err != nil {
		return nil, nil, err
	}

	var invalidProvenance []ngmodels.AlertRuleKey
	result := make([]ngmodels.AlertRuleKey, 0, len(rules))
	updates := make([]ngmodels.UpdateRule, 0, len(rules))
	for _, rule := range rules {
		provenance, ok := provenances[rule.UID]
		if !ok {
			provenance = ngmodels.ProvenanceNone
		}
		if !validateProvenance(provenance) {
			invalidProvenance = append(invalidProvenance, rule.GetKey())
		}
		if len(invalidProvenance) > 0 { // do not do any fixes if there is at least one rule with not allowed provenance
			continue
		}

		result = append(result, rule.GetKey())

		if dryRun {
			continue
		}

		r := rule.Copy()
		settings := r.ContactPointRouting()
		if settings == nil {
			continue
		}
		for mtIdx := range settings.MuteTimeIntervals {
			if settings.MuteTimeIntervals[mtIdx] == oldTimeInterval {
				settings.MuteTimeIntervals[mtIdx] = newTimeInterval
			}
		}
		for mtIdx := range settings.ActiveTimeIntervals {
			if settings.ActiveTimeIntervals[mtIdx] == oldTimeInterval {
				settings.ActiveTimeIntervals[mtIdx] = newTimeInterval
			}
		}

		updates = append(updates, ngmodels.UpdateRule{
			Existing: rule,
			New:      *r,
		})
	}
	if len(invalidProvenance) > 0 {
		return nil, invalidProvenance, nil
	}
	if dryRun {
		return result, nil, nil
	}
	// Provide empty user identifier to ensure it's clear that the rule update was made by the system
	// and not by the user who changed the receiver's title.
	return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
}

// GetNamespacesByRuleUID returns a map of rule UIDs to their namespace UID.
func (st DBstore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) {
	result := make(map[string]string)
	err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
		var rules []alertRule
		err := sess.Table(alertRule{}).Select("uid, namespace_uid").Where("org_id = ?", orgID).In("uid", uids).Find(&rules)
		if err != nil {
			return err
		}
		for _, rule := range rules {
			result[rule.UID] = rule.NamespaceUID
		}
		return nil
	})
	return result, err
}

func (st DBstore) CleanUpDeletedAlertRules(ctx context.Context) (int64, error) {
	affectedRows := int64(-1)
	err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
		expire := TimeNow().Add(-st.Cfg.DeletedRuleRetention)
		st.Logger.Debug("Permanently remove expired deleted rules", "deletedBefore", expire)
		result, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_uid='' AND created <= ?", expire)
		if err != nil {
			return err
		}
		affectedRows, err = result.RowsAffected()
		if err != nil {
			st.Logger.Warn("Failed to get rows affected by the delete operation", "error", err)
		}
		return nil
	})
	return affectedRows, err
}

func getINSubQueryArgs[T any](inputSlice []T) ([]any, []string) {
	args := make([]any, 0, len(inputSlice))
	in := make([]string, 0, len(inputSlice))
	for _, t := range inputSlice {
		args = append(args, t)
		in = append(in, "?")
	}

	return args, in
}

func (st DBstore) DeleteRuleFromTrashByGUID(ctx context.Context, orgID int64, ruleGUID string) (int64, error) {
	affectedRows := int64(-1)
	err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
		st.Logger.FromContext(ctx).Debug("Deleting a deleted rule by GUID", "ruleGUID", ruleGUID)
		result, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_uid='' AND rule_org_id = ? AND rule_guid = ? ", orgID, ruleGUID)
		if err != nil {
			return err
		}
		affectedRows, err = result.RowsAffected()
		if err != nil {
			st.Logger.FromContext(ctx).Warn("Failed to get rows affected by the delete operation", "error", err)
		}
		return nil
	})
	return affectedRows, err
}
